Compare commits

..

61 Commits

Author SHA1 Message Date
ZiWei
5dc81ec9be bump version to 0.10.3
update registry

do not modify axis globally

Prcix9320 (#207)

* 0.10.7 Update (#101)

* Cleanup registry to be easy-understanding (#76)

* delete deprecated mock devices

* rename categories

* combine chromatographic devices

* rename rviz simulation nodes

* organic virtual devices

* parse vessel_id

* run registry completion before merge

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>

* fix: workstation handlers and vessel_id parsing

* fix: working dir error when input config path
feat: report publish topic when error

* modify default discovery_interval to 15s

* feat: add trace log level

* feat: 添加ChinWe设备控制类,支持串口通信和电机控制功能 (#79)

* fix: drop_tips not using auto resource select

* fix: discard_tips error

* fix: discard_tips

* fix: prcxi_res

* add: prcxi res
fix: startup slow

* feat: workstation example

* fix pumps and liquid_handler handle

* feat: 优化protocol node节点运行日志

* fix all protocol_compilers and remove deprecated devices

* feat: 新增use_remote_resource参数

* fix and remove redundant info

* bugfixes on organic protocols

* fix filter protocol

* fix protocol node

* 临时兼容错误的driver写法

* fix: prcxi import error

* use call_async in all service to avoid deadlock

* fix: figure_resource

* Update recipe.yaml

* add workstation template and battery example

* feat: add sk & ak

* update workstation base

* Create workstation_architecture.md

* refactor: workstation_base 重构为仅含业务逻辑,通信和子设备管理交给 ProtocolNode

* refactor: ProtocolNode→WorkstationNode

* Add:msgs.action (#83)

* update: Workstation dev 将版本号从 0.10.3 更新为 0.10.4 (#84)

* Add:msgs.action

* update: 将版本号从 0.10.3 更新为 0.10.4

* simplify resource system

* uncompleted refactor

* example for use WorkstationBase

* feat: websocket

* feat: websocket test

* feat: workstation example

* feat: action status

* fix: station自己的方法注册错误

* fix: 还原protocol node处理方法

* fix: build

* fix: missing job_id key

* ws test version 1

* ws test version 2

* ws protocol

* 增加物料关系上传日志

* 增加物料关系上传日志

* 修正物料关系上传

* 修复工站的tracker实例追踪失效问题

* 增加handle检测,增加material edge关系上传

* 修复event loop错误

* 修复edge上报错误

* 修复async错误

* 更新schema的title字段

* 主机节点信息等支持自动刷新

* 注册表编辑器

* 修复status密集发送时,消息出错

* 增加addr参数

* fix: addr param

* fix: addr param

* 取消labid 和 强制config输入

* Add action definitions for LiquidHandlerSetGroup and LiquidHandlerTransferGroup

- Created LiquidHandlerSetGroup.action with fields for group name, wells, and volumes.
- Created LiquidHandlerTransferGroup.action with fields for source and target group names and unit volume.
- Both actions include response fields for return information and success status.

* Add LiquidHandlerSetGroup and LiquidHandlerTransferGroup actions to CMakeLists

* Add set_group and transfer_group methods to PRCXI9300Handler and update liquid_handler.yaml

* result_info改为字典类型

* 新增uat的地址替换

* runze multiple pump support

(cherry picked from commit 49354fcf39)

* remove runze multiple software obtainer

(cherry picked from commit 8bcc92a394)

* support multiple backbone

(cherry picked from commit 4771ff2347)

* Update runze pump format

* Correct runze multiple backbone

* Update runze_multiple_backbone

* Correct runze pump multiple receive method.

* Correct runze pump multiple receive method.

* 对于PRCXI9320的transfer_group,一对多和多对多

* 移除MQTT,更新launch文档,提供注册表示例文件,更新到0.10.5

* fix import error

* fix dupe upload registry

* refactor ws client

* add server timeout

* Fix: run-column with correct vessel id (#86)

* fix run_column

* Update run_column_protocol.py

(cherry picked from commit e5aa4d940a)

* resource_update use resource_add

* 新增版位推荐功能

* 重新规定了版位推荐的入参

* update registry with nested obj

* fix protocol node log_message, added create_resource return value

* fix protocol node log_message, added create_resource return value

* try fix add protocol

* fix resource_add

* 修复移液站错误的aspirate注册表

* Feature/xprbalance-zhida (#80)

* feat(devices): add Zhida GC/MS pretreatment automation workstation

* feat(devices): add mettler_toledo xpr balance

* balance

* 重新补全zhida注册表

* PRCXI9320 json

* PRCXI9320 json

* PRCXI9320 json

* fix resource download

* remove class for resource

* bump version to 0.10.6

* 更新所有注册表

* 修复protocolnode的兼容性

* 修复protocolnode的兼容性

* Update install md

* Add Defaultlayout

* 更新物料接口

* fix dict to tree/nested-dict converter

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* frontend_docs

* create/update resources with POST/PUT for big amount/ small amount data

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

* Workstation templates: Resources and its CRUD, and workstation tasks (#95)

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

---------

Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>

* 更新物料接口

* Workstation dev yb2 (#100)

* Refactor and extend reaction station action messages

* Refactor dispensing station tasks to enhance parameter clarity and add batch processing capabilities

- Updated `create_90_10_vial_feeding_task` to include detailed parameters for 90%/10% vial feeding, improving clarity and usability.
- Introduced `create_batch_90_10_vial_feeding_task` for batch processing of 90%/10% vial feeding tasks with JSON formatted input.
- Added `create_batch_diamine_solution_task` for batch preparation of diamine solution, also utilizing JSON formatted input.
- Refined `create_diamine_solution_task` to include additional parameters for better task configuration.
- Enhanced schema descriptions and default values for improved user guidance.

* 修复to_plr_resources

* add update remove

* 支持选择器注册表自动生成
支持转运物料

* 修复资源添加

* 修复transfer_resource_to_another生成

* 更新transfer_resource_to_another参数,支持spot入参

* 新增test_resource动作

* fix host_node error

* fix host_node test_resource error

* fix host_node test_resource error

* 过滤本地动作

* 移动内部action以兼容host node

* 修复同步任务报错不显示的bug

* feat: 允许返回非本节点物料,后面可以通过decoration进行区分,就不进行warning了

* update todo

* modify bioyond/plr converter, bioyond resource registry, and tests

* pass the tests

* update todo

* add conda-pack-build.yml

* add auto install script for conda-pack-build.yml

(cherry picked from commit 172599adcf)

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* Add version in __init__.py
Update conda-pack-build.yml
Add create_zip_archive.py

* Update conda-pack-build.yml

* Update conda-pack-build.yml (with mamba)

* Update conda-pack-build.yml

* Fix FileNotFoundError

* Try fix 'charmap' codec can't encode characters in position 16-23: character maps to <undefined>

* Fix unilabos msgs search error

* Fix environment_check.py

* Update recipe.yaml

* Update registry. Update uuid loop figure method. Update install docs.

* Fix nested conda pack

* Fix one-key installation path error

* Bump version to 0.10.7

* Workshop bj (#99)

* Add LaiYu Liquid device integration and tests

Introduce LaiYu Liquid device implementation, including backend, controllers, drivers, configuration, and resource files. Add hardware connection, tip pickup, and simplified test scripts, as well as experiment and registry configuration for LaiYu Liquid. Documentation and .gitignore for the device are also included.

* feat(LaiYu_Liquid): 重构设备模块结构并添加硬件文档

refactor: 重新组织LaiYu_Liquid模块目录结构
docs: 添加SOPA移液器和步进电机控制指令文档
fix: 修正设备配置中的最大体积默认值
test: 新增工作台配置测试用例
chore: 删除过时的测试脚本和配置文件

* add

* 重构: 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py 并更新所有导入引用

- 使用 git mv 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py
- 更新所有相关文件中的导入引用
- 保持代码功能不变,仅改善命名一致性
- 测试确认所有导入正常工作

* 修复: 在 core/__init__.py 中添加 LaiYuLiquidBackend 导出

- 添加 LaiYuLiquidBackend 到导入列表
- 添加 LaiYuLiquidBackend 到 __all__ 导出列表
- 确保所有主要类都可以正确导入

* 修复大小写文件夹名字

* 电池装配工站二次开发教程(带目录)上传至dev (#94)

* 电池装配工站二次开发教程

* Update intro.md

* 物料教程

* 更新物料教程,json格式注释

* Update prcxi driver & fix transfer_liquid mix_times (#90)

* Update prcxi driver & fix transfer_liquid mix_times

* fix: correct mix_times type

* Update liquid_handler registry

* test: prcxi.py

* Update registry from pr

* fix ony-key script not exist

* clean files

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>
Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
Co-authored-by: Guangxin Zhang <guangxin.zhang.bio@gmail.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>
Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: LccLink <1951855008@qq.com>
Co-authored-by: lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com>
Co-authored-by: shiyubo0410 <shiyubo@dp.tech>

* fix startup env check.
add auto install during one-key installation

* Try fix one-key build on linux

* Complete all one key installation

* fix: rename schema field to resource_schema with serialization and validation aliases (#104)

Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>

* Fix one-key installation build

Install conda-pack before pack command

Add conda-pack to base when building one-key installer

Fix param error when using mamba run

Try fix one-key build on linux

* Fix conda pack on windows

* add plr_to_bioyond, and refactor bioyond stations

* modify default config

* Fix one-key installation build for windows

* Fix workstation startup
Update registry

* Fix/resource UUID and doc fix (#109)

* Fix ResourceTreeSet load error

* Raise error when using unsupported type to create ResourceTreeSet

* Fix children key error

* Fix children key error

* Fix workstation resource not tracking

* Fix workstation deck & children resource dupe

* Fix workstation deck & children resource dupe

* Fix multiple resource error

* Fix resource tree update

* Fix resource tree update

* Force confirm uuid

* Tip more error log

* Refactor Bioyond workstation and experiment workflow (#105)

Refactored the Bioyond workstation classes to improve parameter handling and workflow management. Updated experiment.py to use BioyondReactionStation with deck and material mappings, and enhanced workflow step parameter mapping and execution logic. Adjusted JSON experiment configs, improved workflow sequence handling, and added UUID assignment to PLR materials. Removed unused station_config and material cache logic, and added detailed docstrings and debug output for workflow methods.

* Fix resource get.
Fix resource parent not found.
Mapping uuid for all resources.

* mount parent uuid

* Add logging configuration based on BasicConfig in main function

* fix workstation node error

* fix workstation node error

* Update boot example

* temp fix for resource get

* temp fix for resource get

* provide error info when cant find plr type

* pack repo info

* fix to plr type error

* fix to plr type error

* Update regular container method

* support no size init

* fix comprehensive_station.json

* fix comprehensive_station.json

* fix type conversion

* fix state loading for regular container

* Update deploy-docs.yml

* Update deploy-docs.yml

---------

Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>

* Close #107
Update doc url.

* Fix/update resource (#112)

* cancel upload_registry

* Refactor Bioyond workstation and experiment workflow -fix (#111)

* refactor(bioyond_studio): 优化材料缓存加载和参数验证逻辑

改进材料缓存加载逻辑以支持多种材料类型和详细材料处理
更新工作流参数验证中的字段名从key/value改为Key/DisplayValue
移除未使用的merge_workflow_with_parameters方法
添加get_station_info方法获取工作站基础信息
清理实验文件中的注释代码和更新导入路径

* fix: 修复资源移除时的父资源检查问题

在BaseROS2DeviceNode中,移除资源前添加对父资源是否为None的检查,避免空指针异常
同时更新Bottle和BottleCarrier类以支持**kwargs参数
修正测试文件中Liquid_feeding_beaker的大小写拼写错误

* correct return message

---------

Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>

* fix resource_get in action

* fix(reaction_station): 清空工作流序列和参数避免重复执行 (#113)

在创建任务后清空工作流序列和参数,防止下次执行时累积重复

* Update create_resource device_id

* Update ResourceTracker

add more enumeration in POSE

fix converter in resource_tracker

* Update graphio together with workstation design.

fix(reaction_station): 为步骤参数添加Value字段传个BY后端

fix(bioyond/warehouses): 修正仓库尺寸和物品排列参数

调整仓库的x轴和z轴物品数量以及物品尺寸参数,使其符合4x1x4的规格要求

fix warehouse serialize/deserialize

fix bioyond converter

fix itemized_carrier.unassign_child_resource

allow not-loaded MSG in registry

add layout serializer & converter

warehouseuse A1-D4; add warehouse layout

fix(graphio): 修正bioyond到plr资源转换中的坐标计算错误

Fix resource assignment and type mapping issues

Corrects resource assignment in ItemizedCarrier by using the correct spot key from _ordering. Updates graphio to use 'typeName' instead of 'name' for type mapping in resource_bioyond_to_plr. Renames DummyWorkstation to BioyondWorkstation in workstation_http_service for clarity.

* Update workstation & bioyond example

Refine descriptions in Bioyond reaction station YAML

Updated and clarified field and operation descriptions in the reaction_station_bioyond.yaml file for improved accuracy and consistency. Changes include more precise terminology, clearer parameter explanations, and standardized formatting for operation schemas.

refactor(workstation): 更新反应站参数描述并添加分液站配置文件

修正反应站方法参数描述,使其更准确清晰
添加bioyond_dispensing_station.yaml配置文件

add create_workflow script and test

add invisible_slots to carriers

fix(warehouses): 修正bioyond_warehouse_1x4x4仓库的尺寸参数

调整仓库的num_items_x和num_items_z值以匹配实际布局,并更新物品尺寸参数

save resource get data. allow empty value for layout and cross_section_type

More decks&plates support for bioyond (#115)

refactor(registry): 重构反应站设备配置,简化并更新操作命令

移除旧的自动操作命令,新增针对具体化学操作的命令配置
更新模块路径和配置结构,优化参数定义和描述

fix(dispensing_station): 修正物料信息查询方法调用

将直接调用material_id_query改为通过hardware_interface调用,以符合接口设计规范

* PRCXI Update

修改prcxi连线

prcxi样例图

Create example_prcxi.json

* Update resource extra & uuid.

use ordering to convert identifier to idx

convert identifier to site idx

correct extra key

update extra before transfer

fix multiple instance error

add resource_tree_transfer func

fox itemrized carrier assign child resource

support internal device material transfer

remove extra key

use same callback group

support material extra

support material extra
support update_resource_site in extra

* Update workstation.

modify workstation_architecture docs

bioyond_HR (#133)

* feat: Enhance Bioyond synchronization and resource management

- Implemented synchronization for all material types (consumables, samples, reagents) from Bioyond, logging detailed information for each type.
- Improved error handling and logging during synchronization processes.
- Added functionality to save Bioyond material IDs in UniLab resources for future updates.
- Enhanced the `sync_to_external` method to handle material movements correctly, including querying and creating materials in Bioyond.
- Updated warehouse configurations to support new storage types and improved layout for better resource management.
- Introduced new resource types such as reactors and tip boxes, with detailed specifications.
- Modified warehouse factory to support column offsets for naming conventions (e.g., A05-D08).
- Improved resource tracking by merging extra attributes instead of overwriting them.
- Added a new method for updating resources in Bioyond, ensuring better synchronization of resource changes.

* feat: 添加TipBox和Reactor的配置到bottles.yaml

* fix: 修复液体投料方法中的volume参数处理逻辑

修复solid_feeding_vials方法中的volume参数处理逻辑,优化solvents参数的使用条件

更新液体投料方法,支持通过溶剂信息自动计算体积,添加solvents参数并更新文档描述

Add batch creation methods for vial and solution tasks

添加批量创建90%10%小瓶投料任务和二胺溶液配置任务的功能,更新相关参数和默认值

* 封膜仪、撕膜仪、耗材站接口

* 添加Raman和xrd相关代码

* Resource update & asyncio fix

correct bioyond config

prcxi example

fix append_resource

fix regularcontainer

fix cancel error

fix resource_get param

fix json dumps

support name change during materials change

enable slave mode

change uuid logger to trace level

correct remove_resource stats

disable slave connect websocket

adjust with_children param

modify devices to use correct executor (sleep, create_task)

support sleep and create_task in node

fix run async execution error

* bump version to 0.10.9

update registry

* PRCXI Reset Error Correction (#166)

* change 9320 desk row number to 4

* Updated 9320 host address

* Updated 9320 host address

* Add **kwargs in classes: PRCXI9300Deck and PRCXI9300Container

* Removed all sample_id in prcxi_9320.json to avoid KeyError

* 9320 machine testing settings

* Typo

* Rewrite setup logic to clear error code

* 初始化 step_mode 属性

* 1114物料手册定义教程byxinyu (#165)

* 宜宾奔耀工站deck前端by_Xinyu

* 构建物料教程byxinyu

* 1114物料手册定义教程

* 3d sim (#97)

* 修改lh的json启动

* 修改lh的json启动

* 修改backend,做成sim的通用backend

* 修改yaml的地址,3D模型适配网页生产环境

* 添加laiyu硬件连接

* 修改移液枪的状态判断方法,

修改移液枪的状态判断方法,
添加三轴的表定点与零点之间的转换
添加三轴真实移动的backend

* 修改laiyu移液站

简化移动方法,
取消软件限制位置,
修改当值使用Z轴时也需要重新复位Z轴的问题

* 更新lh以及laiyu workshop

1,现在可以直接通过修改backend,适配其他的移液站,主类依旧使用LiquidHandler,不用重新编写

2,修改枪头判断标准,使用枪头自身判断而不是类的判断,

3,将归零参数用毫米计算,方便手动调整,

4,修改归零方式,上电使用机械归零,确定机械零点,手动归零设置工作区域零点方便计算,二者互不干涉

* 修改枪头动作

* 修改虚拟仿真方法

---------

Co-authored-by: zhangshixiang <@zhangshixiang>
Co-authored-by: Junhan Chang <changjh@dp.tech>

* 标准化opcua设备接入unilab (#78)

* 初始提交,只保留工作区当前状态

* remove redundant arm_slider meshes

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>

* add new laiyu liquid driver, yaml and json files (#164)

* HR物料同步,前端展示位置修复 (#135)

* 更新Bioyond工作站配置,添加新的物料类型映射和载架定义,优化物料查询逻辑

* 添加Bioyond实验配置文件,定义物料类型映射和设备配置

* 更新bioyond_warehouse_reagent_stack方法,修正试剂堆栈尺寸和布局描述

* 更新Bioyond实验配置,修正物料类型映射,优化设备配置

* 更新Bioyond资源同步逻辑,优化物料入库流程,增强错误处理和日志记录

* 更新Bioyond资源,添加配液站和反应站专用载架,优化仓库工厂函数的排序方式

* 更新Bioyond资源,添加配液站和反应站相关载架,优化试剂瓶和样品瓶配置

* 更新Bioyond实验配置,修正试剂瓶载架ID,确保与设备匹配

* 更新Bioyond资源,移除反应站单烧杯载架,添加反应站单烧瓶载架分类

* Refactor Bioyond resource synchronization and update bottle carrier definitions

- Removed traceback printing in error handling for Bioyond synchronization.
- Enhanced logging for existing Bioyond material ID usage during synchronization.
- Added new bottle carrier definitions for single flask and updated existing ones.
- Refactored dispensing station and reaction station bottle definitions for clarity and consistency.
- Improved resource mapping and error handling in graphio for Bioyond resource conversion.
- Introduced layout parameter in warehouse factory for better warehouse configuration.

* 更新Bioyond仓库工厂,添加排序方式支持,优化坐标计算逻辑

* 更新Bioyond载架和甲板配置,调整样品板尺寸和仓库坐标

* 更新Bioyond资源同步,增强占用位置日志信息,修正坐标转换逻辑

* 更新Bioyond反应站和分配站配置,调整材料类型映射和ID,移除不必要的项

* support name change during materials change

* fix json dumps

* correct tip

* 优化调度器API路径,更新相关方法描述

* 更新 BIOYOND 载架相关文档,调整 API 以支持自带试剂瓶的载架类型,修复资源获取时的子物料处理逻辑

* 实现资源删除时的同步处理,优化出库操作逻辑

* 修复 ItemizedCarrier 中的可见性逻辑

* 保存 Bioyond 原始信息到 unilabos_extra,以便出库时查询

* 根据 resource.capacity 判断是试剂瓶(载架)还是多瓶载架,走不同的奔曜转换

* Fix bioyond bottle_carriers ordering

* 优化 Bioyond 物料同步逻辑,增强坐标解析和位置更新处理

* disable slave connect websocket

* correct remove_resource stats

* change uuid logger to trace level

* enable slave mode

* refactor(bioyond): 统一资源命名并优化物料同步逻辑

- 将DispensingStation和ReactionStation资源统一为PolymerStation命名
- 优化物料同步逻辑,支持耗材类型(typeMode=0)的查询
- 添加物料默认参数配置功能
- 调整仓库坐标布局
- 清理废弃资源定义

* feat(warehouses): 为仓库函数添加col_offset和layout参数

* refactor: 更新实验配置中的物料类型映射命名

将DispensingStation和ReactionStation的物料类型映射统一更名为PolymerStation,保持命名一致性

* fix: 更新实验配置中的载体名称从6VialCarrier到6StockCarrier

* feat(bioyond): 实现物料创建与入库分离逻辑

将物料同步流程拆分为两个独立阶段:transfer阶段只创建物料,add阶段执行入库
简化状态检查接口,仅返回连接状态

* fix(reaction_station): 修正液体进料烧杯体积单位并增强返回结果

将液体进料烧杯的体积单位从μL改为g以匹配实际使用场景
在返回结果中添加merged_workflow和order_params字段,提供更完整的工作流信息

* feat(dispensing_station): 在任务创建返回结果中添加order_params信息

在create_order方法返回结果中增加order_params字段,以便调用方获取完整的任务参数

* fix(dispensing_station): 修改90%物料分配逻辑从分成3份改为直接使用

原逻辑将主称固体平均分成3份作为90%物料,现改为直接使用main_portion

* feat(bioyond): 添加任务编码和任务ID的输出,支持批量任务创建后的状态监控

* refactor(registry): 简化设备配置中的任务结果处理逻辑

将多个单独的任务编码和ID字段合并为统一的return_info字段
更新相关描述以反映新的数据结构

* feat(工作站): 添加HTTP报送服务和任务完成状态跟踪

- 在graphio.py中添加API必需字段
- 实现工作站HTTP服务启动和停止逻辑
- 添加任务完成状态跟踪字典和等待方法
- 重写任务完成报送处理方法记录状态
- 支持批量任务完成等待和报告获取

* refactor(dispensing_station): 移除wait_for_order_completion_and_get_report功能

该功能已被wait_for_multiple_orders_and_get_reports替代,简化代码结构

* fix: 更新任务报告API错误

* fix(workstation_http_service): 修复状态查询中device_id获取逻辑

处理状态查询时安全获取device_id,避免因属性不存在导致的异常

* fix(bioyond_studio): 改进物料入库失败时的错误处理和日志记录

在物料入库API调用失败时,添加更详细的错误信息打印
同时修正station.py中对空响应和失败情况的判断逻辑

* refactor(bioyond): 优化瓶架载体的分配逻辑和注释说明

重构瓶架载体的分配逻辑,使用嵌套循环替代硬编码索引分配
添加更详细的坐标映射说明,明确PLR与Bioyond坐标的对应关系

* fix(bioyond_rpc): 修复物料入库成功时无data字段返回空的问题

当API返回成功但无data字段时,返回包含success标识的字典而非空字典

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>
Co-authored-by: Junhan Chang <changjh@dp.tech>

* nmr

* Update devices

* bump version to 0.10.10

* Update repo files.

* Add get_resource_with_dir & get_resource method

* fix camera & workstation & warehouse & reaction station driver

* update docs, test examples
fix liquid_handler init bug

* bump version to 0.10.11

* Add startup_json_path, disable_browser, port config

* Update oss config

* feat(bioyond_studio): 添加项目API接口支持及优化物料管理功能

添加通用项目API接口方法(_post_project_api, _delete_project_api)用于与LIMS系统交互
实现compute_experiment_design方法用于实验设计计算
新增brief_step_parameters等订单相关接口方法
优化物料转移逻辑,增加异步任务处理
扩展BioyondV1RPC类,添加批量物料操作、订单状态管理等功能

* feat(bioyond): 添加测量小瓶仓库和更新仓库工厂函数参数

* Support unilabos_samples key

* add session_id and normal_exit

* Add result schema and add TypedDict conversion.

* Fix port error

* Add backend api and update doc

* Add get_regular_container func

* Add get_regular_container func

* Transfer_liquid (#176)

* change 9320 desk row number to 4

* Updated 9320 host address

* Updated 9320 host address

* Add **kwargs in classes: PRCXI9300Deck and PRCXI9300Container

* Removed all sample_id in prcxi_9320.json to avoid KeyError

* 9320 machine testing settings

* Typo

* Typo in base_device_node.py

* Enhance liquid handling functionality by adding support for multiple transfer modes (one-to-many, one-to-one, many-to-one) and improving parameter validation. Default channel usage is set when not specified. Adjusted mixing logic to ensure it only occurs when valid conditions are met. Updated documentation for clarity.

* Auto dump logs, fix workstation input schema

* Fix startup with remote resource error

Resource dict fully change to "pose" key

Update oss link

Reduce pylabrobot conversion warning & force enable log dump.

更新 logo 图片

* signal when host node is ready

* fix ros2 future

print all logs to file
fix resource dict dump error

* update version to 0.10.12

* 修改sample_uuid的返回值

* 修改pose标签设定机制

* 添加 aspiate函数返回值

* 返回dispense后的sample_uuid

* 添加self.pending_liquids_dict的重置方法

* 修改prcxi的json文件,解决trach错误问题

* 修改prcxijson,防止PlateT4的硬件错误

* 对laiyu移液站进行部分修改,取消多次初始化的问题

* 修改根据新的物料格式,修改可视化

* 添加切换枪头方法,添加mock振荡与加热方法

* 夹爪添加

* 删除多余的laiyu部分

* 云端可启动夹爪

* Delete __init__.py

* Enhance PRCXI9300 classes with new Container and TipRack implementations, improving state management and initialization logic. Update JSON configuration to reflect type changes for containers and plates.

* 修改上传数据

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>
Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
Co-authored-by: Guangxin Zhang <guangxin.zhang.bio@gmail.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>
Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: LccLink <1951855008@qq.com>
Co-authored-by: lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com>
Co-authored-by: shiyubo0410 <shiyubo@dp.tech>
Co-authored-by: hh.(SII) <103566763+Mile-Away@users.noreply.github.com>
Co-authored-by: Xianwei Qi <qxw@stu.pku.edu.cn>
Co-authored-by: WenzheG <wenzheguo32@gmail.com>
Co-authored-by: Harry Liu <113173203+ALITTLELZ@users.noreply.github.com>
Co-authored-by: q434343 <73513873+q434343@users.noreply.github.com>
Co-authored-by: tt <166512503+tt11142023@users.noreply.github.com>
Co-authored-by: xyc <49015816+xiaoyu10031@users.noreply.github.com>
Co-authored-by: zhangshixiang <@zhangshixiang>
Co-authored-by: zhangshixiang <554662886@qq.com>
Co-authored-by: ALITTLELZ <l_LZlz@163.com>

Add topic config

add camera driver (#191)

* add camera driver

* add init.py file to cameraSII driver

增强新威电池测试系统 OSS 上传功能 / Enhanced Neware Battery Test System OSS Upload (#196)

* feat: neware-oss-upload-enhancement

* feat(neware): enhance OSS upload with metadata and workflow handles

Add post process station and related resources (#195)

* 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.

prcxi resource (#202)

* prcxi resource

* prcxi_resource

* Fix upload error not showing.
Support str type category.

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>

Fix upload error not showing.
Support str type category.

feat: introduce `wait_time` command and configurable device communication timeout.

feat: Add `SyringePump` (SY-03B) driver with unified serial/TCP transport for `chinwe` device, including registry and test configurations.
2025-12-26 03:36:48 +08:00
Xuwznln
13a6795657 Update organic syn station. 2025-12-15 02:34:36 +08:00
Xianwei Qi
53219d8b04 Update docs
update "laiyu" missing init file.

fix "laiyu" missing init file.

fix "🐛 fix"

🐛 fix: config file is overwrited by default args even if not be set.

mix

修改了mix,仿真流程报错问题
2025-12-14 13:13:21 +08:00
Xuwznln
b1cdef9185 update version to 0.10.12 2025-12-04 18:47:16 +08:00
Xuwznln
9854ed8c9c fix ros2 future
print all logs to file
fix resource dict dump error
2025-12-04 18:46:37 +08:00
Xuwznln
52544a2c69 signal when host node is ready 2025-12-02 12:00:26 +08:00
ZiWei
5ce433e235 Fix startup with remote resource error
Resource dict fully change to "pose" key

Update oss link

Reduce pylabrobot conversion warning & force enable log dump.

更新 logo 图片
2025-12-02 11:51:01 +08:00
Xuwznln
c7c14d2332 Auto dump logs, fix workstation input schema 2025-11-27 14:24:40 +08:00
Harry Liu
6fdd482649 Transfer_liquid (#176)
* change 9320 desk row number to 4

* Updated 9320 host address

* Updated 9320 host address

* Add **kwargs in classes: PRCXI9300Deck and PRCXI9300Container

* Removed all sample_id in prcxi_9320.json to avoid KeyError

* 9320 machine testing settings

* Typo

* Typo in base_device_node.py

* Enhance liquid handling functionality by adding support for multiple transfer modes (one-to-many, one-to-one, many-to-one) and improving parameter validation. Default channel usage is set when not specified. Adjusted mixing logic to ensure it only occurs when valid conditions are met. Updated documentation for clarity.
2025-11-27 13:49:04 +08:00
Xuwznln
d390236318 Add get_regular_container func 2025-11-27 13:47:12 +08:00
Xuwznln
ed8ee29732 Add get_regular_container func 2025-11-27 13:46:40 +08:00
Xuwznln
ffc583e9d5 Add backend api and update doc 2025-11-26 19:17:46 +08:00
Xuwznln
f1ad0c9c96 Fix port error 2025-11-25 15:19:15 +08:00
Xuwznln
8fa3407649 Add result schema and add TypedDict conversion. 2025-11-25 15:16:27 +08:00
Xuwznln
d3282822fc add session_id and normal_exit 2025-11-20 22:43:24 +08:00
Xuwznln
554bcade24 Support unilabos_samples key 2025-11-19 15:53:59 +08:00
ZiWei
a662c75de1 feat(bioyond): 添加测量小瓶仓库和更新仓库工厂函数参数 2025-11-19 14:26:12 +08:00
ZiWei
931614fe64 feat(bioyond_studio): 添加项目API接口支持及优化物料管理功能
添加通用项目API接口方法(_post_project_api, _delete_project_api)用于与LIMS系统交互
实现compute_experiment_design方法用于实验设计计算
新增brief_step_parameters等订单相关接口方法
优化物料转移逻辑,增加异步任务处理
扩展BioyondV1RPC类,添加批量物料操作、订单状态管理等功能
2025-11-19 14:26:10 +08:00
Xuwznln
d39662f65f Update oss config 2025-11-19 14:22:03 +08:00
Xuwznln
acf5fdebf8 Add startup_json_path, disable_browser, port config 2025-11-18 18:59:39 +08:00
Xuwznln
7f7b1c13c0 bump version to 0.10.11 2025-11-18 18:47:26 +08:00
Xuwznln
75f09034ff update docs, test examples
fix liquid_handler init bug
2025-11-18 18:42:27 +08:00
ZiWei
549a50220b fix camera & workstation & warehouse & reaction station driver 2025-11-18 18:41:37 +08:00
Xuwznln
4189a2cfbe Add get_resource_with_dir & get_resource method 2025-11-15 22:50:30 +08:00
Xuwznln
48895a9bb1 Update repo files. 2025-11-15 03:15:44 +08:00
Xuwznln
891f126ed6 bump version to 0.10.10 2025-11-15 03:11:37 +08:00
Xuwznln
4d3475a849 Update devices 2025-11-15 03:11:36 +08:00
WenzheG
b475db66df nmr 2025-11-15 03:11:35 +08:00
ZiWei
a625a86e3e HR物料同步,前端展示位置修复 (#135)
* 更新Bioyond工作站配置,添加新的物料类型映射和载架定义,优化物料查询逻辑

* 添加Bioyond实验配置文件,定义物料类型映射和设备配置

* 更新bioyond_warehouse_reagent_stack方法,修正试剂堆栈尺寸和布局描述

* 更新Bioyond实验配置,修正物料类型映射,优化设备配置

* 更新Bioyond资源同步逻辑,优化物料入库流程,增强错误处理和日志记录

* 更新Bioyond资源,添加配液站和反应站专用载架,优化仓库工厂函数的排序方式

* 更新Bioyond资源,添加配液站和反应站相关载架,优化试剂瓶和样品瓶配置

* 更新Bioyond实验配置,修正试剂瓶载架ID,确保与设备匹配

* 更新Bioyond资源,移除反应站单烧杯载架,添加反应站单烧瓶载架分类

* Refactor Bioyond resource synchronization and update bottle carrier definitions

- Removed traceback printing in error handling for Bioyond synchronization.
- Enhanced logging for existing Bioyond material ID usage during synchronization.
- Added new bottle carrier definitions for single flask and updated existing ones.
- Refactored dispensing station and reaction station bottle definitions for clarity and consistency.
- Improved resource mapping and error handling in graphio for Bioyond resource conversion.
- Introduced layout parameter in warehouse factory for better warehouse configuration.

* 更新Bioyond仓库工厂,添加排序方式支持,优化坐标计算逻辑

* 更新Bioyond载架和甲板配置,调整样品板尺寸和仓库坐标

* 更新Bioyond资源同步,增强占用位置日志信息,修正坐标转换逻辑

* 更新Bioyond反应站和分配站配置,调整材料类型映射和ID,移除不必要的项

* support name change during materials change

* fix json dumps

* correct tip

* 优化调度器API路径,更新相关方法描述

* 更新 BIOYOND 载架相关文档,调整 API 以支持自带试剂瓶的载架类型,修复资源获取时的子物料处理逻辑

* 实现资源删除时的同步处理,优化出库操作逻辑

* 修复 ItemizedCarrier 中的可见性逻辑

* 保存 Bioyond 原始信息到 unilabos_extra,以便出库时查询

* 根据 resource.capacity 判断是试剂瓶(载架)还是多瓶载架,走不同的奔曜转换

* Fix bioyond bottle_carriers ordering

* 优化 Bioyond 物料同步逻辑,增强坐标解析和位置更新处理

* disable slave connect websocket

* correct remove_resource stats

* change uuid logger to trace level

* enable slave mode

* refactor(bioyond): 统一资源命名并优化物料同步逻辑

- 将DispensingStation和ReactionStation资源统一为PolymerStation命名
- 优化物料同步逻辑,支持耗材类型(typeMode=0)的查询
- 添加物料默认参数配置功能
- 调整仓库坐标布局
- 清理废弃资源定义

* feat(warehouses): 为仓库函数添加col_offset和layout参数

* refactor: 更新实验配置中的物料类型映射命名

将DispensingStation和ReactionStation的物料类型映射统一更名为PolymerStation,保持命名一致性

* fix: 更新实验配置中的载体名称从6VialCarrier到6StockCarrier

* feat(bioyond): 实现物料创建与入库分离逻辑

将物料同步流程拆分为两个独立阶段:transfer阶段只创建物料,add阶段执行入库
简化状态检查接口,仅返回连接状态

* fix(reaction_station): 修正液体进料烧杯体积单位并增强返回结果

将液体进料烧杯的体积单位从μL改为g以匹配实际使用场景
在返回结果中添加merged_workflow和order_params字段,提供更完整的工作流信息

* feat(dispensing_station): 在任务创建返回结果中添加order_params信息

在create_order方法返回结果中增加order_params字段,以便调用方获取完整的任务参数

* fix(dispensing_station): 修改90%物料分配逻辑从分成3份改为直接使用

原逻辑将主称固体平均分成3份作为90%物料,现改为直接使用main_portion

* feat(bioyond): 添加任务编码和任务ID的输出,支持批量任务创建后的状态监控

* refactor(registry): 简化设备配置中的任务结果处理逻辑

将多个单独的任务编码和ID字段合并为统一的return_info字段
更新相关描述以反映新的数据结构

* feat(工作站): 添加HTTP报送服务和任务完成状态跟踪

- 在graphio.py中添加API必需字段
- 实现工作站HTTP服务启动和停止逻辑
- 添加任务完成状态跟踪字典和等待方法
- 重写任务完成报送处理方法记录状态
- 支持批量任务完成等待和报告获取

* refactor(dispensing_station): 移除wait_for_order_completion_and_get_report功能

该功能已被wait_for_multiple_orders_and_get_reports替代,简化代码结构

* fix: 更新任务报告API错误

* fix(workstation_http_service): 修复状态查询中device_id获取逻辑

处理状态查询时安全获取device_id,避免因属性不存在导致的异常

* fix(bioyond_studio): 改进物料入库失败时的错误处理和日志记录

在物料入库API调用失败时,添加更详细的错误信息打印
同时修正station.py中对空响应和失败情况的判断逻辑

* refactor(bioyond): 优化瓶架载体的分配逻辑和注释说明

重构瓶架载体的分配逻辑,使用嵌套循环替代硬编码索引分配
添加更详细的坐标映射说明,明确PLR与Bioyond坐标的对应关系

* fix(bioyond_rpc): 修复物料入库成功时无data字段返回空的问题

当API返回成功但无data字段时,返回包含success标识的字典而非空字典

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>
Co-authored-by: Junhan Chang <changjh@dp.tech>
2025-11-15 03:11:34 +08:00
xyc
37e0f1037c add new laiyu liquid driver, yaml and json files (#164) 2025-11-15 03:11:33 +08:00
tt
a242253145 标准化opcua设备接入unilab (#78)
* 初始提交,只保留工作区当前状态

* remove redundant arm_slider meshes

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>
2025-11-15 03:11:31 +08:00
q434343
448e0074b7 3d sim (#97)
* 修改lh的json启动

* 修改lh的json启动

* 修改backend,做成sim的通用backend

* 修改yaml的地址,3D模型适配网页生产环境

* 添加laiyu硬件连接

* 修改移液枪的状态判断方法,

修改移液枪的状态判断方法,
添加三轴的表定点与零点之间的转换
添加三轴真实移动的backend

* 修改laiyu移液站

简化移动方法,
取消软件限制位置,
修改当值使用Z轴时也需要重新复位Z轴的问题

* 更新lh以及laiyu workshop

1,现在可以直接通过修改backend,适配其他的移液站,主类依旧使用LiquidHandler,不用重新编写

2,修改枪头判断标准,使用枪头自身判断而不是类的判断,

3,将归零参数用毫米计算,方便手动调整,

4,修改归零方式,上电使用机械归零,确定机械零点,手动归零设置工作区域零点方便计算,二者互不干涉

* 修改枪头动作

* 修改虚拟仿真方法

---------

Co-authored-by: zhangshixiang <@zhangshixiang>
Co-authored-by: Junhan Chang <changjh@dp.tech>
2025-11-15 03:11:30 +08:00
lixinyu1011
304827fc8d 1114物料手册定义教程byxinyu (#165)
* 宜宾奔耀工站deck前端by_Xinyu

* 构建物料教程byxinyu

* 1114物料手册定义教程
2025-11-15 03:11:29 +08:00
Harry Liu
872b3d781f PRCXI Reset Error Correction (#166)
* change 9320 desk row number to 4

* Updated 9320 host address

* Updated 9320 host address

* Add **kwargs in classes: PRCXI9300Deck and PRCXI9300Container

* Removed all sample_id in prcxi_9320.json to avoid KeyError

* 9320 machine testing settings

* Typo

* Rewrite setup logic to clear error code

* 初始化 step_mode 属性
2025-11-15 03:11:29 +08:00
Xuwznln
813400f2b4 bump version to 0.10.9
update registry
2025-11-15 02:45:30 +08:00
Xuwznln
b6dfe2b944 Resource update & asyncio fix
correct bioyond config

prcxi example

fix append_resource

fix regularcontainer

fix cancel error

fix resource_get param

fix json dumps

support name change during materials change

enable slave mode

change uuid logger to trace level

correct remove_resource stats

disable slave connect websocket

adjust with_children param

modify devices to use correct executor (sleep, create_task)

support sleep and create_task in node

fix run async execution error
2025-11-15 02:45:12 +08:00
WenzheG
8807865649 添加Raman和xrd相关代码 2025-11-15 02:44:03 +08:00
Guangxin Zhang
5fc7eb7586 封膜仪、撕膜仪、耗材站接口 2025-11-15 02:44:02 +08:00
ZiWei
9bd72b48e1 Update workstation.
modify workstation_architecture docs

bioyond_HR (#133)

* feat: Enhance Bioyond synchronization and resource management

- Implemented synchronization for all material types (consumables, samples, reagents) from Bioyond, logging detailed information for each type.
- Improved error handling and logging during synchronization processes.
- Added functionality to save Bioyond material IDs in UniLab resources for future updates.
- Enhanced the `sync_to_external` method to handle material movements correctly, including querying and creating materials in Bioyond.
- Updated warehouse configurations to support new storage types and improved layout for better resource management.
- Introduced new resource types such as reactors and tip boxes, with detailed specifications.
- Modified warehouse factory to support column offsets for naming conventions (e.g., A05-D08).
- Improved resource tracking by merging extra attributes instead of overwriting them.
- Added a new method for updating resources in Bioyond, ensuring better synchronization of resource changes.

* feat: 添加TipBox和Reactor的配置到bottles.yaml

* fix: 修复液体投料方法中的volume参数处理逻辑

修复solid_feeding_vials方法中的volume参数处理逻辑,优化solvents参数的使用条件

更新液体投料方法,支持通过溶剂信息自动计算体积,添加solvents参数并更新文档描述

Add batch creation methods for vial and solution tasks

添加批量创建90%10%小瓶投料任务和二胺溶液配置任务的功能,更新相关参数和默认值
2025-11-15 02:43:50 +08:00
Xuwznln
42b78ab4c1 Update resource extra & uuid.
use ordering to convert identifier to idx

convert identifier to site idx

correct extra key

update extra before transfer

fix multiple instance error

add resource_tree_transfer func

fox itemrized carrier assign child resource

support internal device material transfer

remove extra key

use same callback group

support material extra

support material extra
support update_resource_site in extra
2025-11-15 02:43:13 +08:00
Xianwei Qi
9645609a05 PRCXI Update
修改prcxi连线

prcxi样例图

Create example_prcxi.json
2025-11-15 02:41:30 +08:00
ZiWei
a2a827d7ac Update workstation & bioyond example
Refine descriptions in Bioyond reaction station YAML

Updated and clarified field and operation descriptions in the reaction_station_bioyond.yaml file for improved accuracy and consistency. Changes include more precise terminology, clearer parameter explanations, and standardized formatting for operation schemas.

refactor(workstation): 更新反应站参数描述并添加分液站配置文件

修正反应站方法参数描述,使其更准确清晰
添加bioyond_dispensing_station.yaml配置文件

add create_workflow script and test

add invisible_slots to carriers

fix(warehouses): 修正bioyond_warehouse_1x4x4仓库的尺寸参数

调整仓库的num_items_x和num_items_z值以匹配实际布局,并更新物品尺寸参数

save resource get data. allow empty value for layout and cross_section_type

More decks&plates support for bioyond (#115)

refactor(registry): 重构反应站设备配置,简化并更新操作命令

移除旧的自动操作命令,新增针对具体化学操作的命令配置
更新模块路径和配置结构,优化参数定义和描述

fix(dispensing_station): 修正物料信息查询方法调用

将直接调用material_id_query改为通过hardware_interface调用,以符合接口设计规范
2025-11-15 02:40:54 +08:00
ZiWei
bb3ca645a4 Update graphio together with workstation design.
fix(reaction_station): 为步骤参数添加Value字段传个BY后端

fix(bioyond/warehouses): 修正仓库尺寸和物品排列参数

调整仓库的x轴和z轴物品数量以及物品尺寸参数,使其符合4x1x4的规格要求

fix warehouse serialize/deserialize

fix bioyond converter

fix itemized_carrier.unassign_child_resource

allow not-loaded MSG in registry

add layout serializer & converter

warehouseuse A1-D4; add warehouse layout

fix(graphio): 修正bioyond到plr资源转换中的坐标计算错误

Fix resource assignment and type mapping issues

Corrects resource assignment in ItemizedCarrier by using the correct spot key from _ordering. Updates graphio to use 'typeName' instead of 'name' for type mapping in resource_bioyond_to_plr. Renames DummyWorkstation to BioyondWorkstation in workstation_http_service for clarity.
2025-11-15 02:39:01 +08:00
Junhan Chang
37ee43d19a Update ResourceTracker
add more enumeration in POSE

fix converter in resource_tracker
2025-11-15 02:38:01 +08:00
Xuwznln
bc30f23e34 Update create_resource device_id 2025-10-20 21:45:20 +08:00
ZiWei
166d84afe1 fix(reaction_station): 清空工作流序列和参数避免重复执行 (#113)
在创建任务后清空工作流序列和参数,防止下次执行时累积重复
2025-10-17 13:44:36 +08:00
Junhan Chang
1b43c53015 fix resource_get in action 2025-10-17 13:44:35 +08:00
Xuwznln
d4415f5a35 Fix/update resource (#112)
* cancel upload_registry

* Refactor Bioyond workstation and experiment workflow -fix (#111)

* refactor(bioyond_studio): 优化材料缓存加载和参数验证逻辑

改进材料缓存加载逻辑以支持多种材料类型和详细材料处理
更新工作流参数验证中的字段名从key/value改为Key/DisplayValue
移除未使用的merge_workflow_with_parameters方法
添加get_station_info方法获取工作站基础信息
清理实验文件中的注释代码和更新导入路径

* fix: 修复资源移除时的父资源检查问题

在BaseROS2DeviceNode中,移除资源前添加对父资源是否为None的检查,避免空指针异常
同时更新Bottle和BottleCarrier类以支持**kwargs参数
修正测试文件中Liquid_feeding_beaker的大小写拼写错误

* correct return message

---------

Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
2025-10-17 03:08:15 +08:00
Xuwznln
0260cbbedb Close #107
Update doc url.
2025-10-16 17:26:45 +08:00
Xuwznln
7c440d10ab Fix/resource UUID and doc fix (#109)
* Fix ResourceTreeSet load error

* Raise error when using unsupported type to create ResourceTreeSet

* Fix children key error

* Fix children key error

* Fix workstation resource not tracking

* Fix workstation deck & children resource dupe

* Fix workstation deck & children resource dupe

* Fix multiple resource error

* Fix resource tree update

* Fix resource tree update

* Force confirm uuid

* Tip more error log

* Refactor Bioyond workstation and experiment workflow (#105)

Refactored the Bioyond workstation classes to improve parameter handling and workflow management. Updated experiment.py to use BioyondReactionStation with deck and material mappings, and enhanced workflow step parameter mapping and execution logic. Adjusted JSON experiment configs, improved workflow sequence handling, and added UUID assignment to PLR materials. Removed unused station_config and material cache logic, and added detailed docstrings and debug output for workflow methods.

* Fix resource get.
Fix resource parent not found.
Mapping uuid for all resources.

* mount parent uuid

* Add logging configuration based on BasicConfig in main function

* fix workstation node error

* fix workstation node error

* Update boot example

* temp fix for resource get

* temp fix for resource get

* provide error info when cant find plr type

* pack repo info

* fix to plr type error

* fix to plr type error

* Update regular container method

* support no size init

* fix comprehensive_station.json

* fix comprehensive_station.json

* fix type conversion

* fix state loading for regular container

* Update deploy-docs.yml

* Update deploy-docs.yml

---------

Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
2025-10-16 17:26:07 +08:00
Xuwznln
c85c49817d Fix workstation startup
Update registry
2025-10-13 15:06:30 +08:00
Xuwznln
c70eafa5f0 Fix one-key installation build for windows 2025-10-13 15:06:29 +08:00
Junhan Chang
b64466d443 modify default config 2025-10-13 15:06:26 +08:00
Junhan Chang
ef3f24ed48 add plr_to_bioyond, and refactor bioyond stations 2025-10-13 15:06:25 +08:00
Xuwznln
2a8e8d014b Fix conda pack on windows 2025-10-13 13:19:45 +08:00
Xuwznln
e0da1c7217 Fix one-key installation build
Install conda-pack before pack command

Add conda-pack to base when building one-key installer

Fix param error when using mamba run

Try fix one-key build on linux
2025-10-13 03:33:00 +08:00
hh.(SII)
51d3e61723 fix: rename schema field to resource_schema with serialization and validation aliases (#104)
Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
2025-10-13 03:24:20 +08:00
Xuwznln
6b5765bbf3 Complete all one key installation 2025-10-13 03:24:19 +08:00
Xuwznln
eb1f3fbe1c Try fix one-key build on linux 2025-10-13 02:10:05 +08:00
Xuwznln
fb93b1cd94 fix startup env check.
add auto install during one-key installation
2025-10-13 01:59:53 +08:00
Xuwznln
9aeffebde1 0.10.7 Update (#101)
* Cleanup registry to be easy-understanding (#76)

* delete deprecated mock devices

* rename categories

* combine chromatographic devices

* rename rviz simulation nodes

* organic virtual devices

* parse vessel_id

* run registry completion before merge

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>

* fix: workstation handlers and vessel_id parsing

* fix: working dir error when input config path
feat: report publish topic when error

* modify default discovery_interval to 15s

* feat: add trace log level

* feat: 添加ChinWe设备控制类,支持串口通信和电机控制功能 (#79)

* fix: drop_tips not using auto resource select

* fix: discard_tips error

* fix: discard_tips

* fix: prcxi_res

* add: prcxi res
fix: startup slow

* feat: workstation example

* fix pumps and liquid_handler handle

* feat: 优化protocol node节点运行日志

* fix all protocol_compilers and remove deprecated devices

* feat: 新增use_remote_resource参数

* fix and remove redundant info

* bugfixes on organic protocols

* fix filter protocol

* fix protocol node

* 临时兼容错误的driver写法

* fix: prcxi import error

* use call_async in all service to avoid deadlock

* fix: figure_resource

* Update recipe.yaml

* add workstation template and battery example

* feat: add sk & ak

* update workstation base

* Create workstation_architecture.md

* refactor: workstation_base 重构为仅含业务逻辑,通信和子设备管理交给 ProtocolNode

* refactor: ProtocolNode→WorkstationNode

* Add:msgs.action (#83)

* update: Workstation dev 将版本号从 0.10.3 更新为 0.10.4 (#84)

* Add:msgs.action

* update: 将版本号从 0.10.3 更新为 0.10.4

* simplify resource system

* uncompleted refactor

* example for use WorkstationBase

* feat: websocket

* feat: websocket test

* feat: workstation example

* feat: action status

* fix: station自己的方法注册错误

* fix: 还原protocol node处理方法

* fix: build

* fix: missing job_id key

* ws test version 1

* ws test version 2

* ws protocol

* 增加物料关系上传日志

* 增加物料关系上传日志

* 修正物料关系上传

* 修复工站的tracker实例追踪失效问题

* 增加handle检测,增加material edge关系上传

* 修复event loop错误

* 修复edge上报错误

* 修复async错误

* 更新schema的title字段

* 主机节点信息等支持自动刷新

* 注册表编辑器

* 修复status密集发送时,消息出错

* 增加addr参数

* fix: addr param

* fix: addr param

* 取消labid 和 强制config输入

* Add action definitions for LiquidHandlerSetGroup and LiquidHandlerTransferGroup

- Created LiquidHandlerSetGroup.action with fields for group name, wells, and volumes.
- Created LiquidHandlerTransferGroup.action with fields for source and target group names and unit volume.
- Both actions include response fields for return information and success status.

* Add LiquidHandlerSetGroup and LiquidHandlerTransferGroup actions to CMakeLists

* Add set_group and transfer_group methods to PRCXI9300Handler and update liquid_handler.yaml

* result_info改为字典类型

* 新增uat的地址替换

* runze multiple pump support

(cherry picked from commit 49354fcf39)

* remove runze multiple software obtainer

(cherry picked from commit 8bcc92a394)

* support multiple backbone

(cherry picked from commit 4771ff2347)

* Update runze pump format

* Correct runze multiple backbone

* Update runze_multiple_backbone

* Correct runze pump multiple receive method.

* Correct runze pump multiple receive method.

* 对于PRCXI9320的transfer_group,一对多和多对多

* 移除MQTT,更新launch文档,提供注册表示例文件,更新到0.10.5

* fix import error

* fix dupe upload registry

* refactor ws client

* add server timeout

* Fix: run-column with correct vessel id (#86)

* fix run_column

* Update run_column_protocol.py

(cherry picked from commit e5aa4d940a)

* resource_update use resource_add

* 新增版位推荐功能

* 重新规定了版位推荐的入参

* update registry with nested obj

* fix protocol node log_message, added create_resource return value

* fix protocol node log_message, added create_resource return value

* try fix add protocol

* fix resource_add

* 修复移液站错误的aspirate注册表

* Feature/xprbalance-zhida (#80)

* feat(devices): add Zhida GC/MS pretreatment automation workstation

* feat(devices): add mettler_toledo xpr balance

* balance

* 重新补全zhida注册表

* PRCXI9320 json

* PRCXI9320 json

* PRCXI9320 json

* fix resource download

* remove class for resource

* bump version to 0.10.6

* 更新所有注册表

* 修复protocolnode的兼容性

* 修复protocolnode的兼容性

* Update install md

* Add Defaultlayout

* 更新物料接口

* fix dict to tree/nested-dict converter

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* frontend_docs

* create/update resources with POST/PUT for big amount/ small amount data

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

* Workstation templates: Resources and its CRUD, and workstation tasks (#95)

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

---------

Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>

* 更新物料接口

* Workstation dev yb2 (#100)

* Refactor and extend reaction station action messages

* Refactor dispensing station tasks to enhance parameter clarity and add batch processing capabilities

- Updated `create_90_10_vial_feeding_task` to include detailed parameters for 90%/10% vial feeding, improving clarity and usability.
- Introduced `create_batch_90_10_vial_feeding_task` for batch processing of 90%/10% vial feeding tasks with JSON formatted input.
- Added `create_batch_diamine_solution_task` for batch preparation of diamine solution, also utilizing JSON formatted input.
- Refined `create_diamine_solution_task` to include additional parameters for better task configuration.
- Enhanced schema descriptions and default values for improved user guidance.

* 修复to_plr_resources

* add update remove

* 支持选择器注册表自动生成
支持转运物料

* 修复资源添加

* 修复transfer_resource_to_another生成

* 更新transfer_resource_to_another参数,支持spot入参

* 新增test_resource动作

* fix host_node error

* fix host_node test_resource error

* fix host_node test_resource error

* 过滤本地动作

* 移动内部action以兼容host node

* 修复同步任务报错不显示的bug

* feat: 允许返回非本节点物料,后面可以通过decoration进行区分,就不进行warning了

* update todo

* modify bioyond/plr converter, bioyond resource registry, and tests

* pass the tests

* update todo

* add conda-pack-build.yml

* add auto install script for conda-pack-build.yml

(cherry picked from commit 172599adcf)

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* Add version in __init__.py
Update conda-pack-build.yml
Add create_zip_archive.py

* Update conda-pack-build.yml

* Update conda-pack-build.yml (with mamba)

* Update conda-pack-build.yml

* Fix FileNotFoundError

* Try fix 'charmap' codec can't encode characters in position 16-23: character maps to <undefined>

* Fix unilabos msgs search error

* Fix environment_check.py

* Update recipe.yaml

* Update registry. Update uuid loop figure method. Update install docs.

* Fix nested conda pack

* Fix one-key installation path error

* Bump version to 0.10.7

* Workshop bj (#99)

* Add LaiYu Liquid device integration and tests

Introduce LaiYu Liquid device implementation, including backend, controllers, drivers, configuration, and resource files. Add hardware connection, tip pickup, and simplified test scripts, as well as experiment and registry configuration for LaiYu Liquid. Documentation and .gitignore for the device are also included.

* feat(LaiYu_Liquid): 重构设备模块结构并添加硬件文档

refactor: 重新组织LaiYu_Liquid模块目录结构
docs: 添加SOPA移液器和步进电机控制指令文档
fix: 修正设备配置中的最大体积默认值
test: 新增工作台配置测试用例
chore: 删除过时的测试脚本和配置文件

* add

* 重构: 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py 并更新所有导入引用

- 使用 git mv 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py
- 更新所有相关文件中的导入引用
- 保持代码功能不变,仅改善命名一致性
- 测试确认所有导入正常工作

* 修复: 在 core/__init__.py 中添加 LaiYuLiquidBackend 导出

- 添加 LaiYuLiquidBackend 到导入列表
- 添加 LaiYuLiquidBackend 到 __all__ 导出列表
- 确保所有主要类都可以正确导入

* 修复大小写文件夹名字

* 电池装配工站二次开发教程(带目录)上传至dev (#94)

* 电池装配工站二次开发教程

* Update intro.md

* 物料教程

* 更新物料教程,json格式注释

* Update prcxi driver & fix transfer_liquid mix_times (#90)

* Update prcxi driver & fix transfer_liquid mix_times

* fix: correct mix_times type

* Update liquid_handler registry

* test: prcxi.py

* Update registry from pr

* fix ony-key script not exist

* clean files

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>
Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
Co-authored-by: Guangxin Zhang <guangxin.zhang.bio@gmail.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>
Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: LccLink <1951855008@qq.com>
Co-authored-by: lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com>
Co-authored-by: shiyubo0410 <shiyubo@dp.tech>
2025-10-12 23:34:26 +08:00
280 changed files with 329878 additions and 17657 deletions

View File

@@ -1,6 +1,6 @@
package:
name: unilabos
version: 0.10.10
version: 0.10.13
source:
path: ../unilabos

View File

@@ -1,3 +1,4 @@
recursive-include unilabos/test *
recursive-include unilabos/registry *.yaml
recursive-include unilabos/app/web/static *
recursive-include unilabos/app/web/templates *

View File

@@ -39,7 +39,9 @@ Uni-Lab-OS recommends using `mamba` for environment management. Choose the appro
```bash
# Create new environment
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
mamba create -n unilab python=3.11.11
mamba activate unilab
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
```
## Install Dev Uni-Lab-OS

View File

@@ -41,7 +41,9 @@ Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适
```bash
# 创建新环境
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
mamba create -n unilab python=3.11.11
mamba activate unilab
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
```
2. 安装开发版 Uni-Lab-OS:

View File

@@ -0,0 +1,726 @@
# Uni-Lab 配置指南
本文档详细介绍 Uni-Lab 配置文件的结构、配置项、命令行覆盖和环境变量的使用方法。
## 配置文件概述
Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_data/local_config.py`。配置文件采用类属性的方式定义各种配置项,比 YAML 或 JSON 提供更多的灵活性,包括支持注释、条件逻辑和复杂数据结构。
## 获取实验室密钥
在配置文件或启动命令中您需要提供实验室的访问密钥ak和私钥sk
**获取方式:**
进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk
![copy_aksk.gif](image/copy_aksk.gif)
## 配置文件格式
### 默认配置示例
首次使用时,系统会自动创建一个基础配置文件 `local_config.py`
```python
# unilabos的配置文件
class BasicConfig:
ak = "" # 实验室网页给您提供的ak代码
sk = "" # 实验室网页给您提供的sk代码
# WebSocket配置一般无需调整
class WSConfig:
reconnect_interval = 5 # 重连间隔(秒)
max_reconnect_attempts = 999 # 最大重连次数
ping_interval = 30 # ping间隔
```
### 完整配置示例
您可以根据需要添加更多配置选项:
```python
#!/usr/bin/env python
# coding=utf-8
"""Uni-Lab 配置文件"""
# 基础配置
class BasicConfig:
ak = "" # 实验室访问密钥
sk = "" # 实验室私钥
working_dir = "" # 工作目录(通常自动设置)
config_path = "" # 配置文件路径(自动设置)
is_host_mode = True # 是否为主站模式
slave_no_host = False # 从站模式下是否跳过等待主机服务
upload_registry = False # 是否上传注册表
machine_name = "undefined" # 机器名称(自动获取)
vis_2d_enable = False # 是否启用2D可视化
enable_resource_load = True # 是否启用资源加载
communication_protocol = "websocket" # 通信协议
log_level = "DEBUG" # 日志级别TRACE, DEBUG, INFO, WARNING, ERROR, CRITICAL
# WebSocket配置
class WSConfig:
reconnect_interval = 5 # 重连间隔(秒)
max_reconnect_attempts = 999 # 最大重连次数
ping_interval = 30 # ping间隔
# HTTP配置
class HTTPConfig:
remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址
# ROS配置
class ROSConfig:
modules = [
"std_msgs.msg",
"geometry_msgs.msg",
"control_msgs.msg",
"control_msgs.action",
"nav2_msgs.action",
"unilabos_msgs.msg",
"unilabos_msgs.action",
] # 需要加载的ROS模块
```
## 配置优先级
配置项的生效优先级从高到低为:
1. **命令行参数**:最高优先级
2. **环境变量**:中等优先级
3. **配置文件**:基础优先级
这意味着命令行参数会覆盖环境变量和配置文件,环境变量会覆盖配置文件。
## 推荐配置方式
根据参数特性,不同配置项有不同的推荐配置方式:
### 建议通过命令行指定的参数(不需要写入配置文件)
以下参数推荐通过命令行或环境变量指定,**一般不需要在配置文件中配置**
| 参数 | 命令行参数 | 原因 |
| ----------------- | ------------------- | ------------------------------------ |
| `ak` / `sk` | `--ak` / `--sk` | **安全考虑**:避免敏感信息泄露 |
| `working_dir` | `--working_dir` | **灵活性**:不同环境可能使用不同目录 |
| `is_host_mode` | `--is_slave` | **运行模式**:由启动场景决定,不固定 |
| `slave_no_host` | `--slave_no_host` | **运行模式**:从站特殊配置,按需使用 |
| `upload_registry` | `--upload_registry` | **临时操作**:仅首次启动或更新时需要 |
| `vis_2d_enable` | `--2d_vis` | **调试功能**:按需临时启用 |
| `remote_addr` | `--addr` | **环境切换**:测试/生产环境快速切换 |
**推荐用法示例:**
```bash
# 标准启动命令(所有必要参数通过命令行指定)
unilab --ak your_ak --sk your_sk -g graph.json
# 测试环境
unilab --addr test --ak your_ak --sk your_sk -g graph.json
# 从站模式
unilab --is_slave --ak your_ak --sk your_sk
# 首次启动上传注册表
unilab --ak your_ak --sk your_sk -g graph.json --upload_registry
```
### 适合在配置文件中配置的参数
以下参数适合在配置文件中配置,通常不会频繁更改:
| 参数 | 配置类 | 说明 |
| ------------------------ | ----------- | ---------------------- |
| `log_level` | BasicConfig | 日志级别配置 |
| `reconnect_interval` | WSConfig | WebSocket 重连间隔 |
| `max_reconnect_attempts` | WSConfig | WebSocket 最大重连次数 |
| `ping_interval` | WSConfig | WebSocket 心跳间隔 |
| `modules` | ROSConfig | ROS 模块列表 |
**配置文件示例(推荐最小配置):**
```python
# unilabos的配置文件
class BasicConfig:
log_level = "INFO" # 生产环境建议 INFO调试时用 DEBUG
# WebSocket配置一般保持默认即可
class WSConfig:
reconnect_interval = 5
max_reconnect_attempts = 999
ping_interval = 30
```
**注意:** `ak``sk` 不建议写在配置文件中,始终通过命令行参数或环境变量传递。
## 命令行参数覆盖配置
Uni-Lab 允许通过命令行参数覆盖配置文件中的设置,提供更灵活的配置方式。
### 支持命令行覆盖的配置项
| 配置类 | 配置字段 | 命令行参数 | 说明 |
| ------------- | ----------------- | ------------------- | -------------------------------- |
| `BasicConfig` | `ak` | `--ak` | 实验室访问密钥 |
| `BasicConfig` | `sk` | `--sk` | 实验室私钥 |
| `BasicConfig` | `working_dir` | `--working_dir` | 工作目录路径 |
| `BasicConfig` | `is_host_mode` | `--is_slave` | 主站模式(参数为从站模式,取反) |
| `BasicConfig` | `slave_no_host` | `--slave_no_host` | 从站模式下跳过等待主机服务 |
| `BasicConfig` | `upload_registry` | `--upload_registry` | 启动时上传注册表信息 |
| `BasicConfig` | `vis_2d_enable` | `--2d_vis` | 启用 2D 可视化 |
| `HTTPConfig` | `remote_addr` | `--addr` | 远程服务地址 |
### 特殊命令行参数
除了直接覆盖配置项的参数外,还有一些特殊的命令行参数:
| 参数 | 说明 |
| ------------------- | ------------------------------------ |
| `--config` | 指定配置文件路径 |
| `--port` | Web 服务端口(不影响配置文件) |
| `--disable_browser` | 禁用自动打开浏览器(不影响配置文件) |
| `--visual` | 可视化工具选择(不影响配置文件) |
| `--skip_env_check` | 跳过环境检查(不影响配置文件) |
### 命令行覆盖使用示例
```bash
# 通过命令行覆盖认证信息
unilab --ak "new_access_key" --sk "new_secret_key" -g graph.json
# 覆盖服务器地址
unilab --ak ak --sk sk --addr "https://custom.server.com/api/v1" -g graph.json
# 启用从站模式并跳过等待主机
unilab --is_slave --slave_no_host --ak ak --sk sk
# 启用上传注册表和2D可视化
unilab --upload_registry --2d_vis --ak ak --sk sk -g graph.json
# 组合使用多个覆盖参数
unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g graph.json
```
### 预设环境地址
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL
- `test``https://uni-lab.test.bohrium.com/api/v1`
- `uat``https://uni-lab.uat.bohrium.com/api/v1`
- `local``http://127.0.0.1:48197/api/v1`
- 其他值 → 直接使用作为完整 URL
## 配置选项详解
### 1. BasicConfig - 基础配置
基础配置包含了系统运行的核心参数:
| 参数 | 类型 | 默认值 | 说明 |
| ------------------------ | ---- | ------------- | ------------------------------------------ |
| `ak` | str | `""` | 实验室访问密钥(必需) |
| `sk` | str | `""` | 实验室私钥(必需) |
| `working_dir` | str | `""` | 工作目录,通常自动设置 |
| `config_path` | str | `""` | 配置文件路径,自动设置 |
| `is_host_mode` | bool | `True` | 是否为主站模式 |
| `slave_no_host` | bool | `False` | 从站模式下是否跳过等待主机服务 |
| `upload_registry` | bool | `False` | 启动时是否上传注册表信息 |
| `machine_name` | str | `"undefined"` | 机器名称,自动从 hostname 获取(不可配置) |
| `vis_2d_enable` | bool | `False` | 是否启用 2D 可视化 |
| `enable_resource_load` | bool | `True` | 是否启用资源加载 |
| `communication_protocol` | str | `"websocket"` | 通信协议,固定为 websocket |
| `log_level` | str | `"DEBUG"` | 日志级别 |
#### 日志级别选项
- `TRACE` - 追踪级别(最详细)
- `DEBUG` - 调试级别(默认)
- `INFO` - 信息级别
- `WARNING` - 警告级别
- `ERROR` - 错误级别
- `CRITICAL` - 严重错误级别(最简略)
#### 认证配置ak / sk
`ak``sk` 是必需的认证参数:
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
2. **配置方式**
- **命令行参数**`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
- **环境变量**`UNILABOS_BASICCONFIG_AK``UNILABOS_BASICCONFIG_SK`
- **配置文件**:在 `BasicConfig` 类中设置(不推荐,安全风险)
3. **安全注意**:请妥善保管您的密钥信息,不要提交到版本控制
**推荐做法**
- **开发环境**:使用命令行参数或环境变量
- **生产环境**:使用环境变量
- **临时测试**:使用命令行参数
### 2. WSConfig - WebSocket 配置
WebSocket 是 Uni-Lab 的主要通信方式:
| 参数 | 类型 | 默认值 | 说明 |
| ------------------------ | ---- | ------ | ------------------ |
| `reconnect_interval` | int | `5` | 断线重连间隔(秒) |
| `max_reconnect_attempts` | int | `999` | 最大重连次数 |
| `ping_interval` | int | `30` | 心跳检测间隔(秒) |
### 3. HTTPConfig - HTTP 配置
HTTP 客户端配置用于与云端服务通信:
| 参数 | 类型 | 默认值 | 说明 |
| ------------- | ---- | -------------------------------------- | ------------ |
| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 |
**预设环境地址**
- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认)
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
- 本地环境:`http://127.0.0.1:48197/api/v1`
### 4. ROSConfig - ROS 配置
配置 ROS 消息转换器需要加载的模块:
| 配置项 | 类型 | 默认值 | 说明 |
| --------- | ---- | ---------- | ------------ |
| `modules` | list | 见下方示例 | ROS 模块列表 |
**默认模块列表:**
```python
class ROSConfig:
modules = [
"std_msgs.msg", # 标准消息类型
"geometry_msgs.msg", # 几何消息类型
"control_msgs.msg", # 控制消息类型
"control_msgs.action", # 控制动作类型
"nav2_msgs.action", # 导航动作类型
"unilabos_msgs.msg", # UniLab 自定义消息类型
"unilabos_msgs.action", # UniLab 自定义动作类型
]
```
您可以根据实际使用的设备和功能添加其他 ROS 模块。
## 环境变量配置
Uni-Lab 支持通过环境变量覆盖配置文件中的设置。
### 环境变量命名规则
```
UNILABOS_<配置类名>_<配置项名>
```
**注意:**
- 环境变量名不区分大小写
- 配置类名和配置项名都会转换为大写进行匹配
### 设置环境变量
#### Linux / macOS
```bash
# 临时设置(当前终端)
export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO
export UNILABOS_BASICCONFIG_AK="your_access_key"
export UNILABOS_BASICCONFIG_SK="your_secret_key"
# 永久设置(添加到 ~/.bashrc 或 ~/.zshrc
echo 'export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO' >> ~/.bashrc
source ~/.bashrc
```
#### Windows (cmd)
```cmd
# 临时设置
set UNILABOS_BASICCONFIG_LOG_LEVEL=INFO
set UNILABOS_BASICCONFIG_AK=your_access_key
# 永久设置(系统环境变量)
setx UNILABOS_BASICCONFIG_LOG_LEVEL INFO
```
#### Windows (PowerShell)
```powershell
# 临时设置
$env:UNILABOS_BASICCONFIG_LOG_LEVEL="INFO"
$env:UNILABOS_BASICCONFIG_AK="your_access_key"
# 永久设置
[Environment]::SetEnvironmentVariable("UNILABOS_BASICCONFIG_LOG_LEVEL", "INFO", "User")
```
### 环境变量类型转换
系统会根据配置项的原始类型自动转换环境变量值:
| 原始类型 | 转换规则 |
| -------- | --------------------------------------- |
| `bool` | "true", "1", "yes" → True其他 → False |
| `int` | 转换为整数 |
| `float` | 转换为浮点数 |
| `str` | 直接使用字符串值 |
**示例:**
```bash
# 布尔值
export UNILABOS_BASICCONFIG_IS_HOST_MODE=true # 将设置为 True
export UNILABOS_BASICCONFIG_IS_HOST_MODE=false # 将设置为 False
# 整数
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL=10 # 将设置为 10
# 字符串
export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO # 将设置为 "INFO"
```
### 环境变量示例
```bash
# 设置基础配置
export UNILABOS_BASICCONFIG_AK="your_access_key"
export UNILABOS_BASICCONFIG_SK="your_secret_key"
export UNILABOS_BASICCONFIG_IS_HOST_MODE="true"
# 设置WebSocket配置
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
# 设置HTTP配置
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1"
```
## 配置文件使用方法
### 1. 使用默认配置文件(推荐)
系统会自动查找并加载配置文件:
```bash
# 直接启动,使用默认的 unilabos_data/local_config.py
unilab --ak your_ak --sk your_sk -g graph.json
```
查找顺序:
1. 环境变量 `UNILABOS_BASICCONFIG_CONFIG_PATH` 指定的路径
2. 工作目录下的 `local_config.py`
3. 首次使用时会引导创建配置文件
### 2. 指定配置文件启动
```bash
# 使用指定配置文件启动
unilab --config /path/to/your/config.py --ak ak --sk sk -g graph.json
```
### 3. 配置文件验证
系统启动时会自动验证配置文件:
- **语法检查**:确保 Python 语法正确
- **类型检查**:验证配置项类型是否匹配
- **加载确认**:控制台输出加载成功信息
## 常用配置场景
### 场景 1调整日志级别
**配置文件方式:**
```python
class BasicConfig:
log_level = "INFO" # 生产环境建议使用 INFO 或 WARNING
```
**环境变量方式:**
```bash
export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO
unilab --ak ak --sk sk -g graph.json
```
**命令行方式**(需要配置文件已包含):
```bash
# 配置文件无直接命令行参数,需通过环境变量
UNILABOS_BASICCONFIG_LOG_LEVEL=INFO unilab --ak ak --sk sk -g graph.json
```
### 场景 2配置 WebSocket 重连
**配置文件方式:**
```python
class WSConfig:
reconnect_interval = 10 # 增加重连间隔到 10 秒
max_reconnect_attempts = 100 # 减少最大重连次数到 100 次
```
**环境变量方式:**
```bash
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL=10
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100
```
### 场景 3切换服务器环境
**配置文件方式:**
```python
class HTTPConfig:
remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
```
**环境变量方式:**
```bash
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1
```
**命令行方式(推荐):**
```bash
unilab --addr test --ak your_ak --sk your_sk -g graph.json
```
### 场景 4从站模式配置
**配置文件方式:**
```python
class BasicConfig:
is_host_mode = False # 从站模式
slave_no_host = True # 不等待主机服务
```
**命令行方式(推荐):**
```bash
unilab --is_slave --slave_no_host --ak your_ak --sk your_sk
```
## 最佳实践
### 1. 安全配置
**不要在配置文件中存储敏感信息**
-**不推荐**:在配置文件中明文存储 ak/sk
-**推荐**:使用环境变量或命令行参数
```bash
# 生产环境 - 使用环境变量(推荐)
export UNILABOS_BASICCONFIG_AK="your_access_key"
export UNILABOS_BASICCONFIG_SK="your_secret_key"
unilab -g graph.json
# 或使用命令行参数
unilab --ak "your_access_key" --sk "your_secret_key" -g graph.json
```
**其他安全建议:**
- 不要将包含密钥的配置文件提交到版本控制系统
- 限制配置文件权限:`chmod 600 local_config.py`
- 定期更换访问密钥
- 使用 `.gitignore` 排除配置文件
### 2. 多环境配置
为不同环境创建不同的配置文件:
```
configs/
├── base_config.py # 基础配置(非敏感)
├── dev_config.py # 开发环境
├── test_config.py # 测试环境
├── prod_config.py # 生产环境
└── example_config.py # 示例配置
```
**环境切换示例**
```bash
# 本地开发环境
unilab --config configs/dev_config.py --addr local --ak ak --sk sk -g graph.json
# 测试环境
unilab --config configs/test_config.py --addr test --ak ak --sk sk --upload_registry -g graph.json
# 生产环境
unilab --config configs/prod_config.py --ak "$PROD_AK" --sk "$PROD_SK" -g graph.json
```
### 3. 配置管理
**配置文件最佳实践:**
- 保持配置文件简洁,只包含需要修改的配置项
- 为配置项添加注释说明其作用
- 定期检查和更新配置文件
- 版本控制仅保存示例配置,不包含实际密钥
**命令行参数优先使用场景:**
- 临时测试不同配置
- CI/CD 流水线中的动态配置
- 不同环境间快速切换
- 敏感信息的安全传递
### 4. 灵活配置策略
**基础配置文件 + 命令行覆盖**的推荐方式:
```python
# base_config.py - 基础配置(非敏感信息)
class BasicConfig:
# 非敏感配置写在文件中
is_host_mode = True
upload_registry = False
vis_2d_enable = False
log_level = "INFO"
class WSConfig:
reconnect_interval = 5
max_reconnect_attempts = 999
ping_interval = 30
```
```bash
# 启动时通过命令行覆盖关键参数
unilab --config base_config.py \
--ak "$AK" \
--sk "$SK" \
--addr "test" \
--upload_registry \
--2d_vis \
-g graph.json
```
## 故障排除
### 1. 配置文件加载失败
**错误信息**`[ENV] 配置文件 xxx 不存在`
**解决方法**
- 确认配置文件路径正确
- 检查文件权限是否可读
- 确保配置文件是 `.py` 格式
- 使用绝对路径或相对于当前目录的路径
### 2. 语法错误
**错误信息**`[ENV] 加载配置文件 xxx 失败`
**解决方法**
- 检查 Python 语法是否正确
- 确认类名和字段名拼写正确
- 验证缩进是否正确(使用空格而非制表符)
- 确保字符串使用引号包裹
### 3. 认证失败
**错误信息**`后续运行必须拥有一个实验室`
**解决方法**
- 确认 `ak``sk` 已正确配置
- 检查密钥是否有效(未过期或撤销)
- 确认网络连接正常
- 验证密钥是否来自正确的实验室
### 4. 环境变量不生效
**解决方法**
- 确认环境变量名格式正确(`UNILABOS_<类名>_<字段名>`
- 检查环境变量是否已正确设置(`echo $VARIABLE_NAME`
- 重启终端或重新加载环境变量
- 确认环境变量值的类型正确
### 5. 命令行参数不生效
**错误现象**:设置了命令行参数但配置没有生效
**解决方法**
- 确认参数名拼写正确(如 `--ak` 而不是 `--access_key`
- 检查参数格式是否正确(布尔参数如 `--is_slave` 不需要值)
- 确认参数位置正确(所有参数都应在 `unilab` 之后)
- 查看启动日志确认参数是否被正确解析
- 检查是否有配置文件或环境变量与之冲突
### 6. 配置优先级混淆
**错误现象**:不确定哪个配置生效
**解决方法**
- 记住优先级:**命令行参数 > 环境变量 > 配置文件**
- 使用 `--ak``--sk` 参数时会看到提示信息:"传入了 ak 参数,优先采用传入参数!"
- 检查启动日志中的配置加载信息
- 临时移除低优先级配置来测试高优先级配置是否生效
- 使用 `printenv | grep UNILABOS` 查看所有相关环境变量
## 配置验证
### 检查配置是否生效
启动 Uni-Lab 时,控制台会输出配置加载信息:
```
[ENV] 配置文件 /path/to/config.py 加载成功
[ENV] 设置 BasicConfig.log_level = INFO
传入了ak参数优先采用传入参数
传入了sk参数优先采用传入参数
```
### 常见配置错误
1. **配置文件格式错误**
```
[ENV] 加载配置文件 /path/to/config.py 失败
```
**解决方案**:检查 Python 语法,确保配置类定义正确
2. **环境变量格式错误**
```
[ENV] 环境变量格式不正确UNILABOS_INVALID_VAR
```
**解决方案**:确保环境变量遵循 `UNILABOS_<类名>_<字段名>` 格式
3. **类或字段不存在**
```
[ENV] 未找到类UNKNOWNCONFIG
[ENV] 类 BasicConfig 中未找到字段UNKNOWN_FIELD
```
**解决方案**:检查配置类名和字段名是否正确
## 相关文档
- [工作目录详解](working_directory.md)
- [启动参数详解](../user_guide/launch.md)
- [快速安装指南](../user_guide/quick_install_guide.md)

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

View File

@@ -0,0 +1,218 @@
# 工作目录详解
本文档详细介绍 Uni-Lab 工作目录(`working_dir`)的判断逻辑和详细用法。
## 什么是工作目录
工作目录是 Uni-Lab 存储配置文件、日志和运行数据的目录。默认情况下,工作目录为 `当前目录/unilabos_data`
## 工作目录判断逻辑
系统按以下决策树自动确定工作目录:
### 第一步:初始判断
```python
# 检查当前目录
if 当前目录以 "unilabos_data" 结尾:
working_dir = 当前目录的绝对路径
else:
working_dir = 当前目录/unilabos_data
```
**解释:**
- 如果您已经在 `unilabos_data` 目录内启动,系统直接使用当前目录
- 否则,系统会在当前目录下创建或使用 `unilabos_data` 子目录
### 第二步:处理 `--working_dir` 参数
如果用户指定了 `--working_dir` 参数:
```python
working_dir = 用户指定的路径
```
此时还会检查配置文件:
- 如果同时指定了 `--config` 但该文件不存在
- 系统会尝试在 `working_dir/local_config.py` 查找
- 如果仍未找到,报错退出
### 第三步:处理 `--config` 参数
如果用户指定了 `--config` 且文件存在:
```python
# 工作目录改为配置文件所在目录
working_dir = config_path 的父目录
```
**重要:** 这意味着配置文件的位置会影响工作目录的判断。
## 使用场景示例
### 场景 1默认场景推荐
```bash
# 当前目录:/home/user/project
unilab --ak your_ak --sk your_sk -g graph.json
# 结果:
# working_dir = /home/user/project/unilabos_data
# config_path = /home/user/project/unilabos_data/local_config.py
```
### 场景 2在 unilabos_data 目录内启动
```bash
cd /home/user/project/unilabos_data
unilab --ak your_ak --sk your_sk -g graph.json
# 结果:
# working_dir = /home/user/project/unilabos_data
# config_path = /home/user/project/unilabos_data/local_config.py
```
### 场景 3手动指定工作目录
```bash
unilab --working_dir /custom/path --ak your_ak --sk your_sk -g graph.json
# 结果:
# working_dir = /custom/path
# config_path = /custom/path/local_config.py (如果存在)
```
### 场景 4通过配置文件路径推断工作目录
```bash
unilab --config /data/lab_a/local_config.py --ak your_ak --sk your_sk -g graph.json
# 结果:
# working_dir = /data/lab_a
# config_path = /data/lab_a/local_config.py
```
## 高级用法:管理多个实验室配置
### 方法 1使用不同的工作目录
```bash
# 实验室 A
unilab --working_dir ~/labs/lab_a --ak ak_a --sk sk_a -g graph_a.json
# 实验室 B
unilab --working_dir ~/labs/lab_b --ak ak_b --sk sk_b -g graph_b.json
```
### 方法 2使用不同的配置文件
```bash
# 实验室 A
unilab --config ~/labs/lab_a/config.py --ak ak_a --sk sk_a -g graph_a.json
# 实验室 B
unilab --config ~/labs/lab_b/config.py --ak ak_b --sk sk_b -g graph_b.json
```
### 方法 3使用shell脚本管理
创建 `start_lab_a.sh`
```bash
#!/bin/bash
cd ~/labs/lab_a
unilab --ak your_ak_a --sk your_sk_a -g graph_a.json
```
创建 `start_lab_b.sh`
```bash
#!/bin/bash
cd ~/labs/lab_b
unilab --ak your_ak_b --sk your_sk_b -g graph_b.json
```
## 完整决策流程图
```
开始
判断当前目录是否以 unilabos_data 结尾?
├─ 是 → working_dir = 当前目录
└─ 否 → working_dir = 当前目录/unilabos_data
用户是否指定 --working_dir
└─ 是 → working_dir = 指定路径
用户是否指定 --config 且文件存在?
└─ 是 → working_dir = config 文件所在目录
检查 working_dir/local_config.py 是否存在?
├─ 是 → 加载配置文件 → 继续启动
└─ 否 → 询问是否首次使用
├─ 是 → 创建目录和配置文件 → 继续启动
└─ 否 → 退出程序
```
## 常见问题
### 1. 如何查看当前使用的工作目录?
启动 Uni-Lab 时,系统会在控制台输出:
```
当前工作目录为 /path/to/working_dir
```
### 2. 可以在同一台机器上运行多个实验室吗?
可以。使用不同的工作目录或配置文件即可:
```bash
# 终端 1
unilab --working_dir ~/lab1 --ak ak1 --sk sk1 -g graph1.json
# 终端 2
unilab --working_dir ~/lab2 --ak ak2 --sk sk2 -g graph2.json
```
### 3. 工作目录中存储了什么?
- `local_config.py` - 配置文件
- 日志文件
- 临时运行数据
- 缓存文件
### 4. 可以删除工作目录吗?
可以,但会丢失:
- 配置文件(需要重新创建)
- 历史日志
- 缓存数据
建议定期备份配置文件。
### 5. 如何迁移到新的工作目录?
```bash
# 1. 复制旧的工作目录
cp -r ~/old_path/unilabos_data ~/new_path/unilabos_data
# 2. 在新位置启动
cd ~/new_path
unilab --ak your_ak --sk your_sk -g graph.json
```
## 最佳实践
1. **使用默认工作目录**:对于单一实验室,使用默认的 `./unilabos_data` 即可
2. **组织多实验室**:为每个实验室创建独立的目录结构
3. **版本控制**:将配置文件纳入版本控制,但排除日志和缓存
4. **备份配置**:定期备份 `local_config.py` 文件
5. **使用脚本**:为不同实验室创建启动脚本,简化操作
## 相关文档
- [配置文件指南](configuration.md)
- [启动参数详解](../user_guide/launch.md)

View File

@@ -1,7 +1,7 @@
(instructions)=
# 设备抽象、指令集与通信中间件
Uni-Lab 操作系统的目的是将不同类型和厂家的实验仪器进行抽象统一,对应用层提供服务。因此,理清实验室设备之间的业务逻辑至关重要。
Uni-Lab-OS的目的是将不同类型和厂家的实验仪器进行抽象统一,对应用层提供服务。因此,理清实验室设备之间的业务逻辑至关重要。
## 设备间通信模式

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,18 @@
# 添加设备
# 添加设备:编写驱动
在 Uni-Lab 中设备Device是实验操作的基础单元。Uni-Lab 使用**注册表机制**来兼容管理种类繁多的设备驱动程序。回顾 {ref}`instructions` 中的概念,抽象的设备对外拥有【话题】【服务】【动作】三种通信机制,因此将设备添加进 Uni-Lab实际上是将设备驱动中的三种机制映射到 Uni-Lab 标准指令集上。
在 Uni-Lab 中设备Device是实验操作的基础单元。Uni-Lab 使用**注册表机制**来兼容管理种类繁多的设备驱动程序。抽象的设备对外拥有【话题】【服务】【动作】三种通信机制,因此将设备添加进 Uni-Lab实际上是将设备驱动中的三种机制映射到 Uni-Lab 标准指令集上。
能被 Uni-Lab 添加的驱动程序类型有以下种类:
> **💡 提示:** 本文档介绍如何使用已有的设备驱动SDK。若设备没有现成的驱动程序需要自己开发驱动请参考 {doc}`add_old_device`。
1. Python Class
## 支持的驱动类型
Uni-Lab 支持以下两种驱动程序:
### 1. Python Class推荐
Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用,无需额外编译。
**示例:**
```python
class MockGripper:
@@ -31,12 +39,11 @@ class MockGripper:
def status(self) -> str:
return self._status
# 会被自动识别的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
@status.setter
def status(self, target):
self._status = target
# 需要在注册表添加的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
# 会被自动识别的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
def push_to(self, position: float, torque: float, velocity: float = 0.0):
self._status = "Running"
current_pos = self.position
@@ -53,9 +60,11 @@ class MockGripper:
self._status = "Idle"
```
Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 使用。
### 2. C# Class
2. C# Class
C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能使用(仅需一次)。
**示例:**
```csharp
using System;
@@ -84,7 +93,7 @@ public class MockGripper
position = currentPos + (Position - currentPos) / 20 * (i + 1);
torque = Torque / (20 - i);
velocity = Velocity;
await Task.Delay((int)(moveTime * 1000 / 20)); // Convert seconds to milliseconds
await Task.Delay((int)(moveTime * 1000 / 20));
}
torque = Torque;
status = "Idle";
@@ -92,12 +101,16 @@ public class MockGripper
}
```
C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能使用,但只需一次。
---
## 快速开始:使用注册表编辑器(推荐)
## 快速开始:两种方式添加设备
### 方式 1使用注册表编辑器推荐
推荐使用 Uni-Lab-OS 自带的可视化编辑器,它能自动分析您的设备驱动并生成大部分配置:
**步骤:**
1. 启动 Uni-Lab-OS
2. 在浏览器中打开"注册表编辑器"页面
3. 选择您的 Python 设备驱动文件
@@ -106,13 +119,18 @@ C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能
6. 点击"生成注册表",复制生成的内容
7. 保存到 `devices/` 目录下
---
**优点:**
## 手动编写注册表(简化版)
- 自动识别设备属性和方法
- 可视化界面,易于操作
- 自动生成完整配置
- 减少手动配置错误
### 方式 2手动编写注册表简化版
如果需要手动编写,只需要提供两个必需字段,系统会自动补全其余内容:
### 最小配置示例
**最小配置示例**
```yaml
my_device: # 设备唯一标识符
@@ -121,22 +139,22 @@ my_device: # 设备唯一标识符
type: python # 驱动类型
```
### 注册表文件位置
**注册表文件位置**
- 默认路径:`unilabos/registry/devices`
- 自定义路径:启动时使用 `--registry` 参数指定
- 可将多个设备写在同一个 yaml 文件中
- 自定义路径:启动时使用 `--registry_path` 参数指定
- 可将多个设备写在同一个 YAML 文件中
### 系统自动生成的内容
**系统自动生成的内容**
系统会自动分析您的 Python 驱动类并生成:
- `status_types`:从 `get_*` 方法自动识别状态属性
- `status_types`:从 `@property` 装饰的方法自动识别状态属性
- `action_value_mappings`:从类方法自动生成动作映射
- `init_param_schema`:从 `__init__` 方法分析初始化参数
- `schema`:前端显示用的属性类型定义
### 完整结构概览
**完整结构概览**
```yaml
my_device:
@@ -151,4 +169,848 @@ my_device:
schema: {} # 自动生成
```
详细的注册表编写指南和高级配置,请参考{doc}`yaml 注册表编写指南 <add_yaml>`
> 💡 **提示:** 详细的注册表编写指南和高级配置,请参考 {doc}`03_add_device_registry`
---
## Python 类结构要求
Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构:
```python
from typing import Dict, Any
class MyDevice:
"""设备类文档字符串
说明设备的功能、连接方式等
"""
def __init__(self, config: Dict[str, Any]):
"""初始化设备
Args:
config: 配置字典,来自图文件或注册表
"""
self.port = config.get('port', '/dev/ttyUSB0')
self.baudrate = config.get('baudrate', 9600)
self._status = "idle"
# 初始化硬件连接
@property
def status(self) -> str:
"""设备状态(会自动广播)"""
return self._status
def my_action(self, param: float) -> Dict[str, Any]:
"""执行动作
Args:
param: 参数说明
Returns:
{"success": True, "result": ...}
"""
# 执行设备操作
return {"success": True}
```
## 状态属性 vs 动作方法
### 状态属性(@property
状态属性会被自动识别并定期广播:
```python
@property
def temperature(self) -> float:
"""当前温度"""
return self._read_temperature()
@property
def status(self) -> str:
"""设备状态: idle, running, error"""
return self._status
@property
def is_ready(self) -> bool:
"""设备是否就绪"""
return self._status == "idle"
```
**特点**:
- 使用`@property`装饰器
- 只读,不能有参数
- 自动添加到注册表的`status_types`
- 定期发布到 ROS2 topic
### 动作方法
动作方法是设备可以执行的操作:
```python
def start_heating(self, target_temp: float, rate: float = 1.0) -> Dict[str, Any]:
"""开始加热
Args:
target_temp: 目标温度(°C)
rate: 升温速率(°C/min)
Returns:
{"success": bool, "message": str}
"""
self._status = "heating"
self._target_temp = target_temp
# 发送命令到硬件
return {"success": True, "message": f"Heating to {target_temp}°C"}
async def async_operation(self, duration: float) -> Dict[str, Any]:
"""异步操作(长时间运行)
Args:
duration: 持续时间(秒)
"""
# 使用 self.sleep 而不是 asyncio.sleepROS2 异步机制)
await self.sleep(duration)
return {"success": True}
```
**特点**:
- 普通方法或 async 方法
- 返回 Dict 类型的结果
- 自动注册为 ROS2 Action
- 支持参数和返回值
### 返回值设计指南
> **⚠️ 重要:返回值会自动显示在前端**
>
> 动作方法的返回值(字典)会自动显示在 Web 界面的工作流执行结果中。因此,**强烈建议**设计结构化、可读的返回值字典。
**推荐的返回值结构:**
```python
def my_action(self, param: float) -> Dict[str, Any]:
"""执行操作"""
try:
# 执行操作...
result = self._do_something(param)
return {
"success": True, # 必需:操作是否成功
"message": "操作完成", # 推荐:用户友好的消息
"result": result, # 可选:具体结果数据
"param_used": param, # 可选:记录使用的参数
# 其他有用的信息...
}
except Exception as e:
return {
"success": False,
"error": str(e),
"message": "操作失败"
}
```
**最佳实践示例(参考 `host_node.test_latency`**
```python
def test_latency(self) -> Dict[str, Any]:
"""测试网络延迟
返回值会在前端显示,包含详细的测试结果
"""
# 执行测试...
avg_rtt_ms = 25.5
avg_time_diff_ms = 10.2
test_count = 5
# 返回结构化的测试结果
return {
"status": "success", # 状态标识
"avg_rtt_ms": avg_rtt_ms, # 平均往返时间
"avg_time_diff_ms": avg_time_diff_ms, # 平均时间差
"max_time_error_ms": 5.3, # 最大误差
"task_delay_ms": 15.7, # 任务延迟
"test_count": test_count, # 测试次数
}
```
**前端显示效果:**
当用户在 Web 界面执行工作流时,返回的字典会以 JSON 格式显示在结果面板中:
```json
{
"status": "success",
"avg_rtt_ms": 25.5,
"avg_time_diff_ms": 10.2,
"max_time_error_ms": 5.3,
"task_delay_ms": 15.7,
"test_count": 5
}
```
**返回值设计建议:**
1. **始终包含 `success` 字段**:布尔值,表示操作是否成功
2. **包含 `message` 字段**:字符串,提供用户友好的描述
3. **使用有意义的键名**:使用描述性的键名(如 `avg_rtt_ms` 而不是 `v1`
4. **包含单位**:在键名中包含单位(如 `_ms``_ml``_celsius`
5. **记录重要参数**:返回使用的关键参数值,便于追溯
6. **错误信息详细**:失败时包含 `error` 字段和详细的错误描述
7. **避免返回大数据**:不要返回大型数组或二进制数据,这会影响前端性能
**错误处理示例:**
```python
def risky_operation(self, param: float) -> Dict[str, Any]:
"""可能失败的操作"""
if param < 0:
return {
"success": False,
"error": "参数不能为负数",
"message": f"无效参数: {param}",
"param": param
}
try:
result = self._execute(param)
return {
"success": True,
"message": "操作成功",
"result": result,
"param": param
}
except IOError as e:
return {
"success": False,
"error": "通信错误",
"message": str(e),
"device_status": self._status
}
```
## 特殊参数类型ResourceSlot 和 DeviceSlot
Uni-Lab 提供特殊的参数类型,用于在方法中声明需要选择资源或设备。
### 导入类型
```python
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from typing import List
```
### ResourceSlot - 资源选择
用于需要选择物料资源的场景:
```python
def pipette_liquid(
self,
source: ResourceSlot, # 单个源容器
target: ResourceSlot, # 单个目标容器
volume: float
) -> Dict[str, Any]:
"""从源容器吸取液体到目标容器
Args:
source: 源容器(前端会显示资源选择下拉框)
target: 目标容器(前端会显示资源选择下拉框)
volume: 体积(μL)
"""
print(f"Pipetting {volume}μL from {source.id} to {target.id}")
return {"success": True}
```
**多选示例**:
```python
def mix_multiple(
self,
containers: List[ResourceSlot], # 多个容器选择
speed: float
) -> Dict[str, Any]:
"""混合多个容器
Args:
containers: 容器列表(前端会显示多选下拉框)
speed: 混合速度
"""
for container in containers:
print(f"Mixing {container.name}")
return {"success": True}
```
### DeviceSlot - 设备选择
用于需要选择其他设备的场景:
```python
def coordinate_with_device(
self,
other_device: DeviceSlot, # 单个设备选择
command: str
) -> Dict[str, Any]:
"""与另一个设备协同工作
Args:
other_device: 协同设备(前端会显示设备选择下拉框)
command: 命令
"""
print(f"Coordinating with {other_device.name}")
return {"success": True}
```
**多设备示例**:
```python
def sync_devices(
self,
devices: List[DeviceSlot], # 多个设备选择
sync_signal: str
) -> Dict[str, Any]:
"""同步多个设备
Args:
devices: 设备列表(前端会显示多选下拉框)
sync_signal: 同步信号
"""
for dev in devices:
print(f"Syncing {dev.name}")
return {"success": True}
```
### 完整示例:液体处理工作站
```python
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from typing import List, Dict, Any
class LiquidHandler:
"""液体处理工作站"""
def __init__(self, config: Dict[str, Any]):
self.simulation = config.get('simulation', False)
self._status = "idle"
@property
def status(self) -> str:
return self._status
def transfer_liquid(
self,
source: ResourceSlot, # 源容器选择
target: ResourceSlot, # 目标容器选择
volume: float,
tip: ResourceSlot = None # 可选的枪头选择
) -> Dict[str, Any]:
"""转移液体
前端效果:
- source: 下拉框,列出所有可用容器
- target: 下拉框,列出所有可用容器
- volume: 数字输入框
- tip: 下拉框(可选),列出所有枪头
"""
self._status = "transferring"
# source和target会被解析为实际的资源对象
print(f"Transferring {volume}μL")
print(f" From: {source.id} ({source.name})")
print(f" To: {target.id} ({target.name})")
if tip:
print(f" Using tip: {tip.id}")
# 执行实际的液体转移
# ...
self._status = "idle"
return {
"success": True,
"volume_transferred": volume,
"source_id": source.id,
"target_id": target.id
}
def multi_dispense(
self,
source: ResourceSlot, # 单个源
targets: List[ResourceSlot], # 多个目标
volumes: List[float]
) -> Dict[str, Any]:
"""从一个源分配到多个目标
前端效果:
- source: 单选下拉框
- targets: 多选下拉框(可选择多个容器)
- volumes: 数组输入(每个目标对应一个体积)
"""
results = []
for target, vol in zip(targets, volumes):
print(f"Dispensing {vol}μL to {target.name}")
results.append({
"target": target.id,
"volume": vol
})
return {
"success": True,
"dispense_results": results
}
def test_with_balance(
self,
target: ResourceSlot, # 容器
balance: DeviceSlot # 天平设备
) -> Dict[str, Any]:
"""使用天平测量容器
前端效果:
- target: 容器选择下拉框
- balance: 设备选择下拉框(仅显示天平类型)
"""
print(f"Weighing {target.name} on {balance.name}")
# 可以调用balance的方法
# weight = balance.get_weight()
return {
"success": True,
"container": target.id,
"balance_used": balance.id
}
```
### 工作原理
#### 1. 类型识别
注册表扫描方法签名时:
```python
def my_method(self, resource: ResourceSlot, device: DeviceSlot):
pass
```
系统识别到`ResourceSlot``DeviceSlot`类型。
#### 2. 自动添加 placeholder_keys
在注册表中自动生成:
```yaml
my_device:
class:
action_value_mappings:
my_method:
goal:
resource: resource
device: device
placeholder_keys:
resource: unilabos_resources # 自动添加!
device: unilabos_devices # 自动添加!
```
#### 3. 前端 UI 生成
- `unilabos_resources`: 渲染为资源选择下拉框
- `unilabos_devices`: 渲染为设备选择下拉框
#### 4. 运行时解析
用户选择资源/设备后,实际调用时会传入完整的资源/设备对象:
```python
# 用户在前端选择了 plate_1
# 运行时source参数会收到完整的Resource对象
source.id # "plate_1"
source.name # "96孔板"
source.type # "resource"
source.class_ # "corning_96_wellplate_360ul_flat"
```
## 支持的通信方式
### 1. 串口Serial
```python
import serial
class SerialDevice:
def __init__(self, config: Dict[str, Any]):
self.port = config['port']
self.baudrate = config.get('baudrate', 9600)
self.ser = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=1
)
def send_command(self, cmd: str) -> str:
"""发送命令并读取响应"""
self.ser.write(f"{cmd}\r\n".encode())
response = self.ser.readline().decode().strip()
return response
def __del__(self):
if hasattr(self, 'ser') and self.ser.is_open:
self.ser.close()
```
### 2. TCP/IP Socket
```python
import socket
class TCPDevice:
def __init__(self, config: Dict[str, Any]):
self.host = config['host']
self.port = config['port']
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect((self.host, self.port))
def send_command(self, cmd: str) -> str:
self.sock.sendall(cmd.encode())
response = self.sock.recv(1024).decode()
return response
```
### 3. Modbus
```python
from pymodbus.client import ModbusTcpClient
class ModbusDevice:
def __init__(self, config: Dict[str, Any]):
self.host = config['host']
self.port = config.get('port', 502)
self.client = ModbusTcpClient(self.host, port=self.port)
self.client.connect()
def read_register(self, address: int) -> int:
result = self.client.read_holding_registers(address, 1)
return result.registers[0]
def write_register(self, address: int, value: int):
self.client.write_register(address, value)
```
### 4. OPC UA
```python
from opcua import Client
class OPCUADevice:
def __init__(self, config: Dict[str, Any]):
self.url = config['url']
self.client = Client(self.url)
self.client.connect()
def read_node(self, node_id: str):
node = self.client.get_node(node_id)
return node.get_value()
def write_node(self, node_id: str, value):
node = self.client.get_node(node_id)
node.set_value(value)
```
### 5. HTTP/RPC
```python
import requests
class HTTPDevice:
def __init__(self, config: Dict[str, Any]):
self.base_url = config['url']
self.auth_token = config.get('token')
def send_command(self, endpoint: str, data: Dict) -> Dict:
url = f"{self.base_url}/{endpoint}"
headers = {'Authorization': f'Bearer {self.auth_token}'}
response = requests.post(url, json=data, headers=headers)
return response.json()
```
## 异步 vs 同步方法
### 同步方法(适合快速操作)
```python
def quick_operation(self, param: float) -> Dict[str, Any]:
"""快速操作,立即返回"""
result = self._do_something(param)
return {"success": True, "result": result}
```
### 异步方法(适合耗时操作)
```python
async def long_operation(self, duration: float) -> Dict[str, Any]:
"""长时间运行的操作"""
self._status = "running"
# 使用 ROS2 提供的 sleep 方法(而不是 asyncio.sleep
await self.sleep(duration)
# 可以在过程中发送feedback
# 需要配合ROS2 Action的feedback机制
self._status = "idle"
return {"success": True, "duration": duration}
```
> **⚠️ 重要提示ROS2 异步机制 vs Python asyncio**
>
> Uni-Lab 的设备驱动虽然使用 `async def` 语法,但**底层是 ROS2 的异步机制,而不是 Python 的 asyncio**。
>
> **不能使用的 asyncio 功能:**
>
> - ❌ `asyncio.sleep()` - 会导致 ROS2 事件循环阻塞
> - ❌ `asyncio.create_task()` - 任务不会被 ROS2 正确调度
> - ❌ `asyncio.gather()` - 无法与 ROS2 集成
> - ❌ 其他 asyncio 标准库函数
>
> **应该使用的方法(继承自 BaseROS2DeviceNode**
>
> - ✅ `await self.sleep(seconds)` - ROS2 兼容的睡眠
> - ✅ `await self.create_task(func, **kwargs)` - ROS2 兼容的任务创建
> - ✅ ROS2 的 Action/Service 回调机制
>
> **示例:**
>
> ```python
> async def complex_operation(self, duration: float) -> Dict[str, Any]:
> """正确使用 ROS2 异步方法"""
> self._status = "processing"
>
> # ✅ 正确:使用 self.sleep
> await self.sleep(duration)
>
> # ✅ 正确:创建并发任务
> task = await self.create_task(self._background_work)
>
> # ❌ 错误:不要使用 asyncio
> # await asyncio.sleep(duration) # 这会导致问题!
> # task = asyncio.create_task(...) # 这也不行!
>
> self._status = "idle"
> return {"success": True}
>
> async def _background_work(self):
> """后台任务"""
> await self.sleep(1.0)
> self.lab_logger().info("Background work completed")
> ```
>
> **为什么不能混用?**
>
> ROS2 使用 `rclpy` 的事件循环来管理所有异步操作。如果使用 `asyncio` 的函数,这些操作会在不同的事件循环中运行,导致:
>
> - ROS2 回调无法正确执行
> - 任务可能永远不会完成
> - 程序可能死锁或崩溃
>
> **参考实现:**
>
> `BaseROS2DeviceNode` 提供的方法定义(`base_device_node.py:563-572`
>
> ```python
> async def sleep(self, rel_time: float, callback_group=None):
> """ROS2 兼容的异步睡眠"""
> if callback_group is None:
> callback_group = self.callback_group
> await ROS2DeviceNode.async_wait_for(self, rel_time, callback_group)
>
> @classmethod
> async def create_task(cls, func, trace_error=True, **kwargs) -> Task:
> """ROS2 兼容的任务创建"""
> return ROS2DeviceNode.run_async_func(func, trace_error, **kwargs)
> ```
## 错误处理
### 基本错误处理
```python
def operation_with_error_handling(self, param: float) -> Dict[str, Any]:
"""带错误处理的操作"""
try:
result = self._risky_operation(param)
return {
"success": True,
"result": result
}
except ValueError as e:
return {
"success": False,
"error": "Invalid parameter",
"message": str(e)
}
except IOError as e:
self._status = "error"
return {
"success": False,
"error": "Communication error",
"message": str(e)
}
```
### 自定义异常
```python
class DeviceError(Exception):
"""设备错误基类"""
pass
class DeviceNotReadyError(DeviceError):
"""设备未就绪"""
pass
class DeviceTimeoutError(DeviceError):
"""设备超时"""
pass
class MyDevice:
def operation(self) -> Dict[str, Any]:
if self._status != "idle":
raise DeviceNotReadyError(f"Device is {self._status}")
# 执行操作
return {"success": True}
```
## 最佳实践
### 1. 类型注解
```python
from typing import Dict, Any, Optional, List
def method(
self,
param1: float,
param2: str,
optional_param: Optional[int] = None
) -> Dict[str, Any]:
"""完整的类型注解有助于自动生成注册表"""
pass
```
### 2. 文档字符串
```python
def method(self, param: float) -> Dict[str, Any]:
"""方法简短描述
更详细的说明...
Args:
param: 参数说明,包括单位和范围
Returns:
Dict包含:
- success (bool): 是否成功
- result (Any): 结果数据
Raises:
DeviceError: 错误情况说明
"""
pass
```
### 3. 配置验证
```python
def __init__(self, config: Dict[str, Any]):
# 验证必需参数
required = ['port', 'baudrate']
for key in required:
if key not in config:
raise ValueError(f"Missing required config: {key}")
self.port = config['port']
self.baudrate = config['baudrate']
```
### 4. 资源清理
```python
def __del__(self):
"""析构函数,清理资源"""
if hasattr(self, 'connection') and self.connection:
self.connection.close()
```
### 5. 设计前端友好的返回值
**记住:返回值会直接显示在 Web 界面**
```python
import time
def measure_temperature(self) -> Dict[str, Any]:
"""测量温度
✅ 好的返回值设计:
- 包含 success 状态
- 使用描述性键名
- 在键名中包含单位
- 记录测量时间
"""
temp = self._read_temperature()
return {
"success": True,
"temperature_celsius": temp, # 键名包含单位
"timestamp": time.time(), # 记录时间
"sensor_status": "normal", # 额外状态信息
"message": f"温度测量完成: {temp}°C" # 用户友好的消息
}
def bad_example(self) -> Dict[str, Any]:
"""❌ 不好的返回值设计"""
return {
"s": True, # ❌ 键名不明确
"v": 25.5, # ❌ 没有说明单位
"t": 1234567890, # ❌ 不清楚是什么时间戳
}
```
**参考 `host_node.test_latency` 方法**(第 1216-1340 行),它返回详细的测试结果,在前端清晰显示:
```python
return {
"status": "success",
"avg_rtt_ms": 25.5, # 有意义的键名 + 单位
"avg_time_diff_ms": 10.2,
"max_time_error_ms": 5.3,
"task_delay_ms": 15.7,
"test_count": 5, # 记录重要信息
}
```
## 下一步
看完本文档后,建议继续阅读:
- {doc}`add_action` - 了解如何添加新的动作指令
- {doc}`add_yaml` - 学习如何编写和完善 YAML 注册表
进阶主题:
- {doc}`03_add_device_registry` - 了解如何配置注册表
- {doc}`04_add_device_testing` - 学习如何测试设备
- {doc}`add_old_device` - 没有 SDK 时如何开发设备驱动
## 参考
- [Python 类型注解](https://docs.python.org/3/library/typing.html)
- [ROS2 rclpy 异步编程](https://docs.ros.org/en/humble/Tutorials/Intermediate/Writing-an-Action-Server-Client/Py.html) - Uni-Lab 使用 ROS2 的异步机制
- [串口通信](https://pyserial.readthedocs.io/)
> **注意:** 虽然设备驱动使用 `async def` 语法,但请**不要参考** Python 标准的 [asyncio 文档](https://docs.python.org/3/library/asyncio.html)。Uni-Lab 使用的是 ROS2 的异步机制,两者不兼容。请使用 `self.sleep()` 和 `self.create_task()` 等 BaseROS2DeviceNode 提供的方法。

View File

@@ -1,8 +1,10 @@
# 设备 Driver 开发
# 设备 Driver 开发(无 SDK 设备)
我们对设备 Driver 的定义,是一个 Python/C++/C# 类,类的方法可以用于获取传感器数据、执行设备动作、更新物料信息。它们经过 Uni-Lab 的通信中间件包装,就能成为高效分布式通信的设备节点。
因此,若已有设备的 SDK (Driver),可以直接 [添加进 Uni-Lab](add_device.md)。仅当没有 SDK (Driver) 时,请参考本章开发。
因此,若已有设备的 SDK (Driver),可以直接 [添加进 Uni-Lab](add_device.md)。**仅当没有 SDK (Driver) 时,请参考本章进行驱动开发。**
> **💡 提示:** 本文档介绍如何为没有现成驱动的老设备开发驱动程序。如果您的设备已经有 SDK 或驱动,请直接参考 {doc}`add_device`
## 有串口字符串指令集文档的设备Python 串口通信(常见 RS485, RS232, USB
@@ -12,13 +14,13 @@
Modbus 与 RS485、RS232 不一样的地方在于会有更多直接寄存器的读写以及涉及字节序转换Big Endian, Little Endian
Uni-Lab 开发团队在仓库中提供了3个样例:
Uni-Lab 开发团队在仓库中提供了 3 个样例:
* 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
* 单一通信设备**IO板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
* 执行多设备复杂任务逻辑的**PLC**Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
- 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
- 单一通信设备**IO 板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
- 执行多设备复杂任务逻辑的**PLC**Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
****
---
## 其他工业通信协议CANopen, Ethernet, OPCUA...
@@ -26,32 +28,32 @@ Uni-Lab 开发团队在仓库中提供了3个样例
## 没有接口的老设备老软件:使用 PyWinAuto
**pywinauto**是一个 Python 库用于自动化Windows GUI操作。它可以模拟用户的鼠标点击、键盘输入、窗口操作等广泛应用于自动化测试、GUI自动化等场景。它支持通过两个后端进行操作
**pywinauto**是一个 Python 库,用于自动化 Windows GUI 操作。它可以模拟用户的鼠标点击、键盘输入、窗口操作等广泛应用于自动化测试、GUI 自动化等场景。它支持通过两个后端进行操作:
* **win32**后端适用于大多数Windows应用程序使用native Win32 API。pywinauto_recorder默认使用win32后端
* **uia**后端基于Microsoft UI Automation适用于较新的应用程序特别是基于WPFUWP的应用程序。在win10上会有更全的目录有的窗口win32会识别不到
- **win32**后端:适用于大多数 Windows 应用程序,使用 native Win32 API。pywinauto_recorder 默认使用 win32 后端)
- **uia**后端:基于 Microsoft UI Automation适用于较新的应用程序特别是基于 WPFUWP 的应用程序。(在 win10 上,会有更全的目录,有的窗口 win32 会识别不到)
### windows平台安装pywinautopywinauto_recorder
### windows 平台安装 pywinautopywinauto_recorder
直接安装会造成环境崩溃,需要下载并解压已经修改好的文件。
cd到对应目录执行安装
cd 到对应目录,执行安装
`pip install . -i ``https://pypi.tuna.tsinghua.edu.cn/simple`
` pip install . -i ``https://pypi.tuna.tsinghua.edu.cn/simple `
![pywinauto_install](image/device_driver/pywinauto_install.png)
windows平台测试 python pywinauto_recorder.py退出使用两次ctrl+alt+r取消选中关闭命令提示符。
windows 平台测试 python pywinauto_recorder.py退出使用两次 ctrl+alt+r 取消选中,关闭命令提示符。
### 计算器例子
你可以先打开windows的计算器然后在ilab的环境中运行下面的代码片段可观察到得到结果通过这一案例你需要掌握的pywinauto用法
你可以先打开 windows 的计算器,然后在 ilab 的环境中运行下面的代码片段,可观察到得到结果,通过这一案例,你需要掌握的 pywinauto 用法:
* 连接到指定进程
* 利用dump_tree查找需要的窗口
* 获取某个位置的信息
* 模拟点击
* 模拟输入
- 连接到指定进程
- 利用 dump_tree 查找需要的窗口
- 获取某个位置的信息
- 模拟点击
- 模拟输入
#### 代码学习
@@ -74,39 +76,39 @@ window.dump_tree(depth=3)
Dialog - '计算器' (L-419, T773, R-73, B1287)
['计算器Dialog', 'Dialog', '计算器', '计算器Dialog0', '计算器Dialog1', 'Dialog0', 'Dialog1', '计算器0', '计算器1']
child_window(title="计算器", control_type="Window")
|
|
| Dialog - '计算器' (L-269, T774, R-81, B806)
| ['计算器Dialog2', 'Dialog2', '计算器2']
| child_window(title="计算器", auto_id="TitleBar", control_type="Window")
| |
| |
| | Menu - '系统' (L0, T0, R0, B0)
| | ['Menu', '系统', '系统Menu', '系统0', '系统1']
| | child_window(title="系统", auto_id="SystemMenuBar", control_type="MenuBar")
| |
| |
| | Button - '最小化 计算器' (L-219, T774, R-173, B806)
| | ['Button', '最小化 计算器Button', '最小化 计算器', 'Button0', 'Button1']
| | child_window(title="最小化 计算器", auto_id="Minimize", control_type="Button")
| |
| |
| | Button - '使 计算器 最大化' (L-173, T774, R-127, B806)
| | ['Button2', '使 计算器 最大化', '使 计算器 最大化Button']
| | child_window(title="使 计算器 最大化", auto_id="Maximize", control_type="Button")
| |
| |
| | Button - '关闭 计算器' (L-127, T774, R-81, B806)
| | ['Button3', '关闭 计算器Button', '关闭 计算器']
| | child_window(title="关闭 计算器", auto_id="Close", control_type="Button")
|
|
| Dialog - '计算器' (L-411, T774, R-81, B1279)
| ['计算器Dialog3', 'Dialog3', '计算器3']
| child_window(title="计算器", control_type="Window")
| |
| |
| | Static - '计算器' (L-363, T782, R-327, B798)
| | ['计算器Static', 'Static', '计算器4', 'Static0', 'Static1']
| | child_window(title="计算器", auto_id="AppName", control_type="Text")
| |
| |
| | Custom - '' (L-411, T806, R-81, B1279)
| | ['Custom', '计算器Custom']
| | child_window(auto_id="NavView", control_type="Custom")
|
|
| Pane - '' (L-411, T806, R-81, B1279)
| ['Pane', '计算器Pane']
"""
@@ -122,58 +124,58 @@ target_window.dump_tree(depth=3)
Custom - '' (L-411, T806, R-81, B1279)
['标准Custom', 'Custom']
child_window(auto_id="NavView", control_type="Custom")
|
|
| Button - '打开导航' (L-407, T812, R-367, B848)
| ['打开导航Button', '打开导航', 'Button', 'Button0', 'Button1']
| child_window(title="打开导航", auto_id="TogglePaneButton", control_type="Button")
| |
| |
| | Static - '' (L0, T0, R0, B0)
| | ['Static', 'Static0', 'Static1']
| | child_window(auto_id="PaneTitleTextBlock", control_type="Text")
|
|
| GroupBox - '' (L-411, T814, R-81, B1275)
| ['标准GroupBox', 'GroupBox', 'GroupBox0', 'GroupBox1']
| |
| |
| | Static - '表达式为 ' (L0, T0, R0, B0)
| | ['表达式为 ', 'Static2', '表达式为 Static']
| | child_window(title="表达式为 ", auto_id="CalculatorExpression", control_type="Text")
| |
| |
| | Static - '显示为 0' (L-411, T875, R-81, B947)
| | ['显示为 0Static', '显示为 0', 'Static3']
| | child_window(title="显示为 0", auto_id="CalculatorResults", control_type="Text")
| |
| |
| | Button - '打开历史记录浮出控件' (L-121, T814, R-89, B846)
| | ['打开历史记录浮出控件', '打开历史记录浮出控件Button', 'Button2']
| | child_window(title="打开历史记录浮出控件", auto_id="HistoryButton", control_type="Button")
| |
| |
| | GroupBox - '记忆控件' (L-407, T948, R-85, B976)
| | ['记忆控件', '记忆控件GroupBox', 'GroupBox2']
| | child_window(title="记忆控件", auto_id="MemoryPanel", control_type="Group")
| |
| |
| | GroupBox - '显示控件' (L-407, T978, R-85, B1026)
| | ['显示控件', 'GroupBox3', '显示控件GroupBox']
| | child_window(title="显示控件", auto_id="DisplayControls", control_type="Group")
| |
| |
| | GroupBox - '标准函数' (L-407, T1028, R-166, B1076)
| | ['标准函数', '标准函数GroupBox', 'GroupBox4']
| | child_window(title="标准函数", auto_id="StandardFunctions", control_type="Group")
| |
| |
| | GroupBox - '标准运算符' (L-164, T1028, R-85, B1275)
| | ['标准运算符', '标准运算符GroupBox', 'GroupBox5']
| | child_window(title="标准运算符", auto_id="StandardOperators", control_type="Group")
| |
| |
| | GroupBox - '数字键盘' (L-407, T1078, R-166, B1275)
| | ['GroupBox6', '数字键盘', '数字键盘GroupBox']
| | child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
| |
| |
| | Button - '正负' (L-407, T1228, R-328, B1275)
| | ['Button32', '正负Button', '正负']
| | child_window(title="正负", auto_id="negateButton", control_type="Button")
|
|
| Static - '标准' (L-363, T815, R-322, B842)
| ['标准', '标准Static', 'Static4']
| child_window(title="标准", auto_id="Header", control_type="Text")
|
|
| Button - '始终置顶' (L-312, T814, R-280, B846)
| ['始终置顶Button', '始终置顶', 'Button33']
| child_window(title="始终置顶", auto_id="NormalAlwaysOnTopButton", control_type="Button")
@@ -187,47 +189,47 @@ numpad.dump_tree(depth=2)
GroupBox - '数字键盘' (L-334, T1350, R-93, B1547)
['GroupBox', '数字键盘', '数字键盘GroupBox']
child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
|
| Button - '零' (L-253, T1500, R-174, B1547)
| ['零Button', 'Button', '零', 'Button0', 'Button1']
| child_window(title="零", auto_id="num0Button", control_type="Button")
|
|
| Button - '一' (L-334, T1450, R-255, B1498)
| ['一Button', 'Button2', '一']
| child_window(title="一", auto_id="num1Button", control_type="Button")
|
|
| Button - '二' (L-253, T1450, R-174, B1498)
| ['Button3', '二', '二Button']
| child_window(title="二", auto_id="num2Button", control_type="Button")
|
|
| Button - '三' (L-172, T1450, R-93, B1498)
| ['Button4', '三', '三Button']
| child_window(title="三", auto_id="num3Button", control_type="Button")
|
|
| Button - '四' (L-334, T1400, R-255, B1448)
| ['四', 'Button5', '四Button']
| child_window(title="四", auto_id="num4Button", control_type="Button")
|
|
| Button - '五' (L-253, T1400, R-174, B1448)
| ['Button6', '五Button', '五']
| child_window(title="五", auto_id="num5Button", control_type="Button")
|
|
| Button - '六' (L-172, T1400, R-93, B1448)
| ['六Button', 'Button7', '六']
| child_window(title="六", auto_id="num6Button", control_type="Button")
|
|
| Button - '七' (L-334, T1350, R-255, B1398)
| ['Button8', '七Button', '七']
| child_window(title="七", auto_id="num7Button", control_type="Button")
|
|
| Button - '八' (L-253, T1350, R-174, B1398)
| ['八', 'Button9', '八Button']
| child_window(title="八", auto_id="num8Button", control_type="Button")
|
|
| Button - '九' (L-172, T1350, R-93, B1398)
| ['Button10', '九', '九Button']
| child_window(title="九", auto_id="num9Button", control_type="Button")
|
|
| Button - '十进制分隔符' (L-172, T1500, R-93, B1547)
| ['十进制分隔符Button', 'Button11', '十进制分隔符']
| child_window(title="十进制分隔符", auto_id="decimalSeparatorButton", control_type="Button")
@@ -262,13 +264,13 @@ r, g, b = pyautogui.pixel(point_x, point_y)
### pywinauto_recorder
pywinauto_recorder是一个配合 pywinauto 使用的工具,用于录制用户的操作,并生成相应的 pywinauto 脚本。这对于一些暂时无法直接调用DLL的函数并且需要模拟用户操作的场景非常有用。同时可以省去仅用pywinauto的一些查找UI步骤。
pywinauto_recorder 是一个配合 pywinauto 使用的工具,用于录制用户的操作,并生成相应的 pywinauto 脚本。这对于一些暂时无法直接调用 DLL 的函数并且需要模拟用户操作的场景非常有用。同时,可以省去仅用 pywinauto 的一些查找 UI 步骤。
#### 运行尝试
请参照 上手尝试-环境创建-3 开启pywinauto_recorder
请参照 上手尝试-环境创建-3 开启 pywinauto_recorder
例如我们这里先启动一个windows自带的计算器软件
例如我们这里先启动一个 windows 自带的计算器软件
![calculator_01](image/device_driver/calculator_01.png)
@@ -286,7 +288,7 @@ with UIPath(u"计算器||Window"):
click(u"九||Button")
```
执行该python脚本可以观察到新开启的计算器被点击了数字9
执行该 python 脚本,可以观察到新开启的计算器被点击了数字 9
![calculator_03](image/device_driver/calculator_03.png)
@@ -308,23 +310,38 @@ window.dump_tree(depth=[int类型数字], filename=None)
GroupBox - '数字键盘' (L-334, T1350, R-93, B1547)
['GroupBox', '数字键盘', '数字键盘GroupBox']
child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
|
| Button - '零' (L-253, T1500, R-174, B1547)
| ['零Button', 'Button', '零', 'Button0', 'Button1']
| child_window(title="零", auto_id="num0Button", control_type="Button")
"""
```
这里以上面计算器的例子对dump_tree进行解读
这里以上面计算器的例子对 dump_tree 进行解读
2~4行为当前对象的窗口
2~4 行为当前对象的窗口
*2行分别是窗体的类型 `GroupBox`,窗体的题目 `数字键盘`,窗体的矩形区域坐标,对应的是屏幕上的位置(左、上、右、下)
*3行是 `['GroupBox', '数字键盘', '数字键盘GroupBox']`,为控件的标识符列表,可以选择任意一个,使用 `child_window(best_match="标识符")`来获取该窗口
*4行是获取该控件的方法,请注意该方法不能保证获取唯一,`title`如果是变化的,也需要删除 `title`参数
- 2 行分别是窗体的类型 `GroupBox`,窗体的题目 `数字键盘`,窗体的矩形区域坐标,对应的是屏幕上的位置(左、上、右、下)
- 3 行是 `['GroupBox', '数字键盘', '数字键盘GroupBox']`,为控件的标识符列表,可以选择任意一个,使用 `child_window(best_match="标识符")`来获取该窗口
- 4 行是获取该控件的方法,请注意该方法不能保证获取唯一,`title`如果是变化的,也需要删除 `title`参数
6~8行为当前对象窗口所包含的子窗口信息信息类型对应2~4行
6~8 行为当前对象窗口所包含的子窗口信息,信息类型对应 2~4
### 窗口获取注意事项
1. 在 `child_window`的时候,并不会立刻报错,只有在执行窗口的信息获取时才会调用,查询窗口是否存在,因此要想确定 `child_window`是否正确,可以调用子窗口对象的属性 `element_info`,来保证窗口存在
1. 在 `child_window`的时候,并不会立刻报错,只有在执行窗口的信息获取时才会调用,查询窗口是否存在,因此要想确定 `child_window`是否正确,可以调用子窗口对象的属性 `element_info`,来保证窗口存在
---
## 下一步
完成设备驱动开发后,建议继续阅读:
- {doc}`add_device` - 了解如何将驱动添加到 Uni-Lab 中
- {doc}`add_action` - 学习如何添加新的动作指令
- {doc}`add_yaml` - 编写和完善 YAML 注册表
进阶主题:
- {doc}`03_add_device_registry` - 详细的注册表配置
- {doc}`04_add_device_testing` - 设备测试指南

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,17 @@
# 电池装配工站接入PLC
# 实例:电池装配工站接入PLC 控制
本指南将引导你完成电池装配工站(以 PLC 控制为例)的接入流程,包括新建工站文件、编写驱动与寄存器读写、生成注册表、上传及注意事项。
> **文档类型**:实际应用案例
> **适用场景**:使用 PLC 控制的电池装配工站接入
> **前置知识**{doc}`../add_device` | {doc}`../add_registry`
本指南以电池装配工站为实际案例,引导你完成 PLC 控制设备的完整接入流程,包括新建工站文件、编写驱动与寄存器读写、生成注册表、上传及注意事项。
## 案例概述
**设备类型**:电池装配工站
**通信方式**Modbus TCP (PLC)
**工站基类**`WorkstationBase`
**主要功能**:电池组装、寄存器读写、数据采集
## 1. 新建工站文件
@@ -39,8 +50,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
self.client = tcp.register_node_list(self.nodes)
```
## 2. 编写驱动与寄存器读写
### 2.1 寄存器示例
@@ -84,49 +93,49 @@ def start_and_read_metrics(self):
完成工站类与驱动后,需要生成(或更新)工站注册表供系统识别。
### 3.1 新增工站设备(或资源)首次生成注册表
首先通过以下命令启动unilab。进入unilab系统状态检查页面
首先通过以下命令启动 unilab。进入 unilab 系统状态检查页面
```bash
python unilabos\app\main.py -g celljson.json --ak <user的AK> --sk <user的SK>
```
点击注册表编辑,进入注册表编辑页面
![Layers](image_add_batteryPLC/unilab_sys_status.png)
![系统状态页面](image_battery_plc/unilab_sys_status.png)
按照图示步骤填写自动生成注册表信息:
![Layers](image_add_batteryPLC/unilab_registry_process.png)
![注册表生成流程](image_battery_plc/unilab_registry_process.png)
步骤说明:
1. 选择新增的工站`coin_cell_assembly.py`文件
2. 点击分析按钮,分析`coin_cell_assembly.py`文件
3. 选择`coin_cell_assembly.py`文件中继承`WorkstationBase`
4. 填写新增的工站.py文件与`unilabos`目录的距离。例如,新增的工站文件`coin_cell_assembly.py`路径为`unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`,则此处填写`unilabos.devices.workstation.coin_cell_assembly`
4. 填写新增的工站.py 文件与`unilabos`目录的距离。例如,新增的工站文件`coin_cell_assembly.py`路径为`unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`,则此处填写`unilabos.devices.workstation.coin_cell_assembly`
5. 此处填写新定义工站的类的名字(名称可以自拟)
6. 填写新的工站注册表备注信息
7. 生成注册表
以上操作步骤完成,则会生成的新的注册表ymal文件,如下图:
![Layers](image_add_batteryPLC/unilab_new_yaml.png)
以上操作步骤完成,则会生成的新的注册表 YAML 文件,如下图:
![生成的YAML文件](image_battery_plc/unilab_new_yaml.png)
### 3.2 添加新生成注册表
`unilabos\registry\devices`目录下新建一个yaml文件此处新建文件命名为`coincellassemblyworkstation_device.yaml`,将上面生成的新的注册表信息粘贴到`coincellassemblyworkstation_device.yaml`文件中。
`unilabos\registry\devices`目录下新建一个 yaml 文件,此处新建文件命名为`coincellassemblyworkstation_device.yaml`,将上面生成的新的注册表信息粘贴到`coincellassemblyworkstation_device.yaml`文件中。
在终端输入以下命令进行注册表补全操作。
```bash
python unilabos\app\register.py --complete_registry
```
### 3.3 启动并上传注册表
新增设备之后启动unilab需要增加`--upload_registry`参数,来上传注册表信息。
新增设备之后,启动 unilab 需要增加`--upload_registry`参数,来上传注册表信息。
```bash
python unilabos\app\main.py -g celljson.json --ak <user的AK> --sk <user的SK> --upload_registry
@@ -134,14 +143,60 @@ python unilabos\app\main.py -g celljson.json --ak <user的AK> --sk <user的SK> -
## 4. 注意事项
- 在新生成的 YAML 中,确认 `module` 指向新工站类,本例中需检查`coincellassemblyworkstation_device.yaml`文件中是否指向了`coin_cell_assembly.py`文件中定义的`CoinCellAssemblyWorkstation`类文件:
### 4.1 验证模块路径
```
在新生成的 YAML 中,确认 `module` 指向新工站类。本例中需检查 `coincellassemblyworkstation_device.yaml` 文件中是否正确指向了 `CoinCellAssemblyWorkstation` 类:
```yaml
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
```
- 首次新增设备(或资源)需要在网页端新增注册表信息,`--complete_registry`补全注册表,`--upload_registry`上传注册表信息。
### 4.2 首次接入流程
- 如果不是新增设备(或资源),仅对工站驱动的.py文件进行了修改则不需要在网页端新增注册表信息。只需要运行补全注册表信息之后上传注册表即可。
首次新增设备(或资源)需要完整流程:
1. ✅ 在网页端生成注册表信息
2. ✅ 使用 `--complete_registry` 补全注册表
3. ✅ 使用 `--upload_registry` 上传注册表信息
### 4.3 驱动更新流程
如果不是新增设备,仅修改了工站驱动的 `.py` 文件:
1. ✅ 运行 `--complete_registry` 补全注册表
2. ✅ 运行 `--upload_registry` 上传注册表
3. ❌ 不需要在网页端重新生成注册表
### 4.4 PLC 通信注意事项
- **握手机制**:若需参数下发,建议在 PLC 端设置标志寄存器并完成握手复位,避免粘连与竞争
- **字节序**FLOAT32 等多字节数据类型需要正确指定字节序(如 `WorderOrder.LITTLE`
- **寄存器映射**:确保 CSV 文件中的寄存器地址与 PLC 实际配置一致
- **连接稳定性**:在初始化时检查 PLC 连接状态,建议添加重连机制
## 5. 扩展阅读
### 相关文档
- {doc}`../add_device` - 设备驱动编写通用指南
- {doc}`../add_registry` - 注册表配置完整指南
- {doc}`../workstation_architecture` - 工站架构详解
### 技术要点
- **Modbus TCP 通信**PLC 通信协议和寄存器读写
- **WorkstationBase**:工站基类的继承和使用
- **寄存器映射**CSV 格式的寄存器配置
- **注册表生成**:自动化工具使用
## 6. 总结
通过本案例,你应该掌握:
1. ✅ 如何创建 PLC 控制的工站驱动
2. ✅ Modbus TCP 通信和寄存器读写
3. ✅ 使用可视化编辑器生成注册表
4. ✅ 注册表的补全和上传流程
5. ✅ 新增设备与更新驱动的区别
这个案例展示了完整的 PLC 设备接入流程,可以作为其他类似设备接入的参考模板。

View File

Before

Width:  |  Height:  |  Size: 428 KiB

After

Width:  |  Height:  |  Size: 428 KiB

View File

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -1,4 +1,8 @@
# 物料构建指南
# 实例:物料构建指南
> **文档类型**:物料系统实战指南
> **适用场景**工作站物料系统构建、Deck/Warehouse/Carrier/Bottle 配置
> **前置知识**PyLabRobot 基础 | 资源管理概念
## 概述

View File

@@ -1,6 +1,10 @@
# 物料教程Resource
# 实例:物料教程Resource
本教程面向 Uni-Lab-OS 的开发者讲解“物料”的核心概念、3种物料格式UniLab、PyLabRobot、奔耀Bioyond及其相互转换方法并说明4种 children 结构表现形式及使用场景。
> **文档类型**:物料系统完整教程
> **适用场景**:物料格式转换、多系统物料对接、资源结构理解
> **前置知识**Python 基础 | JSON 数据结构
本教程面向 Uni-Lab-OS 的开发者,讲解"物料"的核心概念、3种物料格式UniLab、PyLabRobot、奔耀Bioyond及其相互转换方法并说明4种 children 结构表现形式及使用场景。
---

View File

@@ -1,4 +1,8 @@
# 工作站模板架构设计与对接指南
# 实例:工作站模板架构设计与对接指南
> **文档类型**:架构设计指南与实战案例
> **适用场景**:大型工作站接入、子设备管理、物料系统集成
> **前置知识**{doc}`../add_device` | {doc}`../add_registry`
## 0. 问题简介
@@ -6,19 +10,19 @@
### 0.1 自研常量有机工站:最重要的是子设备管理和通信转发
![workstation_organic_yed](image/workstation_architecture/workstation_organic_yed.png)
![workstation_organic_yed](../image/workstation_architecture/workstation_organic_yed.png)
![workstation_organic](image/workstation_architecture/workstation_organic.png)
![workstation_organic](../image/workstation_architecture/workstation_organic.png)
这类工站由开发者自研,组合所有子设备和实验耗材、希望让他们在工作站这一级协调配合;
1. 工作站包含大量已经注册的子设备,可能各自通信组态很不相同;部分设备可能会拥有同一个通信设备作为出口,如2个泵共用1个串口、所有设备共同接入PLC等。
2. 任务系统是统一实现的 protocolsprotocols 中会将高层指令处理成各子设备配合的工作流 json并管理执行、同时更改物料信息
1. 工作站包含大量已经注册的子设备,可能各自通信组态很不相同;部分设备可能会拥有同一个通信设备作为出口,如 2 个泵共用 1 个串口、所有设备共同接入 PLC 等。
2. 任务系统是统一实现的 protocolsprotocols 中会将高层指令处理成各子设备配合的工作流 json 并管理执行、同时更改物料信息
3. 物料系统较为简单直接,如常量有机化学仅为工作站内固定的瓶子,初始化时就已固定;随后在任务执行过程中,记录试剂量更改信息
### 0.2 移液工作站:物料系统和工作流模板管理
![workstation_liquid_handler](image/workstation_architecture/workstation_liquid_handler.png)
![workstation_liquid_handler](../image/workstation_architecture/workstation_liquid_handler.png)
1. 绝大多数情况没有子设备,有时候选配恒温震荡等模块时,接口也由工作站提供
2. 所有任务系统均由工作站本身实现并下发指令有统一的抽象函数可实现pick_up_tips, aspirate, dispense, transfer 等)。有时需要将这些指令组合、转化为工作站的脚本语言,再统一下发。因此会形成大量固定的 protocols。
@@ -26,12 +30,12 @@
### 0.3 厂家开发的定制大型工站
![workstation_by_supplier](image/workstation_architecture/workstation_by_supplier.png)
![workstation_by_supplier](../image/workstation_architecture/workstation_by_supplier.png)
由厂家开发,具备完善的物料系统、任务系统甚至调度系统;由 PLC 或 OpenAPI TCP 协议统一通信
1. 在监控状态时,希望展现子设备的状态;但子设备仅为逻辑概念,通信由工作站上位机接口提供;部分情况下,子设备状态是被记录在文件中的,需要读取
2. 工作站有自己的工作流系统甚至调度系统;可以通过脚本/PLC连续读写来配置工作站可用的工作流
2. 工作站有自己的工作流系统甚至调度系统;可以通过脚本/PLC 连续读写来配置工作站可用的工作流;
3. 部分拥有完善的物料入库、出库、过程记录,需要与 Uni-Lab-OS 物料系统对接
## 1. 整体架构图
@@ -45,7 +49,7 @@ graph TB
RPN[ROS2WorkstationNode<br/>Protocol执行引擎]
WB -.post_init关联.-> RPN
end
subgraph "物料管理系统"
DECK[Deck<br/>PLR本地物料系统]
RS[ResourceSynchronizer<br/>外部物料同步器]
@@ -53,7 +57,7 @@ graph TB
WB --> RS
RS --> DECK
end
subgraph "通信与子设备管理"
HW[hardware_interface<br/>硬件通信接口]
SUBDEV[子设备集合<br/>pumps/grippers/sensors]
@@ -61,7 +65,7 @@ graph TB
RPN --> SUBDEV
HW -.代理模式.-> RPN
end
subgraph "工作流任务系统"
PROTO[Protocol定义<br/>LiquidHandling/PlateHandling]
WORKFLOW[Workflow执行器<br/>步骤管理与编排]
@@ -81,32 +85,32 @@ graph LR
HW2[通信接口<br/>hardware_interface]
HTTP[HTTP服务<br/>WorkstationHTTPService]
end
subgraph "外部物料系统"
BIOYOND[Bioyond物料管理]
LIMS[LIMS系统]
WAREHOUSE[第三方仓储]
end
subgraph "外部硬件系统"
PLC[PLC设备]
SERIAL[串口设备]
ROBOT[机械臂/机器人]
end
subgraph "云端系统"
CLOUD[UniLab云端<br/>资源管理]
MONITOR[监控与调度]
end
BIOYOND <-->|RPC双向同步| DECK2
LIMS -->|HTTP报送| HTTP
WAREHOUSE <-->|API对接| DECK2
PLC <-->|Modbus TCP| HW2
SERIAL <-->|串口通信| HW2
ROBOT <-->|SDK/API| HW2
WS -->|ROS消息| CLOUD
CLOUD -->|任务下发| WS
MONITOR -->|状态查询| WS
@@ -119,40 +123,40 @@ graph TB
subgraph "工作站基类"
BASE[WorkstationBase<br/>抽象基类]
end
subgraph "Bioyond集成工作站"
BW[BioyondWorkstation]
BW_DECK[Deck + Warehouses]
BW_SYNC[BioyondResourceSynchronizer]
BW_HW[BioyondV1RPC]
BW_HTTP[HTTP报送服务]
BW --> BW_DECK
BW --> BW_SYNC
BW --> BW_HW
BW --> BW_HTTP
end
subgraph "纯协议节点"
PN[ProtocolNode]
PN_SUB[子设备集合]
PN_PROTO[Protocol工作流]
PN --> PN_SUB
PN --> PN_PROTO
end
subgraph "PLC控制工作站"
PW[PLCWorkstation]
PW_DECK[Deck物料系统]
PW_PLC[Modbus PLC客户端]
PW_WF[工作流定义]
PW --> PW_DECK
PW --> PW_PLC
PW --> PW_WF
end
BASE -.继承.-> BW
BASE -.继承.-> PN
BASE -.继承.-> PW
@@ -171,25 +175,25 @@ classDiagram
+hardware_interface: Union[Any, str]
+current_workflow_status: WorkflowStatus
+supported_workflows: Dict[str, WorkflowInfo]
+post_init(ros_node)*
+set_hardware_interface(interface)
+call_device_method(method, *args, **kwargs)
+get_device_status()
+is_device_available()
+get_deck()
+get_all_resources()
+find_resource_by_name(name)
+find_resources_by_type(type)
+sync_with_external_system()
+execute_workflow(name, params)
+stop_workflow(emergency)
+workflow_status
+is_busy
}
class ROS2WorkstationNode {
+device_id: str
+children: Dict[str, Any]
@@ -198,7 +202,7 @@ classDiagram
+_action_clients: Dict
+_action_servers: Dict
+resource_tracker: DeviceNodeResourceTracker
+initialize_device(device_id, config)
+create_ros_action_server(action_name, mapping)
+execute_single_action(device_id, action, kwargs)
@@ -206,14 +210,14 @@ classDiagram
+transfer_resource_to_another(resources, target, sites)
+_setup_hardware_proxy(device, comm_device, read, write)
}
%% 物料管理相关类
class Deck {
+name: str
+children: List
+assign_child_resource()
}
class ResourceSynchronizer {
<<abstract>>
+workstation: WorkstationBase
@@ -221,23 +225,23 @@ classDiagram
+sync_to_external(plr_resource)*
+handle_external_change(change_info)*
}
class BioyondResourceSynchronizer {
+bioyond_api_client: BioyondV1RPC
+sync_interval: int
+last_sync_time: float
+initialize()
+sync_from_external()
+sync_to_external(resource)
+handle_external_change(change_info)
}
%% 硬件接口相关类
class HardwareInterface {
<<interface>>
}
class BioyondV1RPC {
+base_url: str
+api_key: str
@@ -245,7 +249,7 @@ classDiagram
+add_material()
+material_inbound()
}
%% 服务类
class WorkstationHTTPService {
+workstation: WorkstationBase
@@ -253,7 +257,7 @@ classDiagram
+port: int
+server: HTTPServer
+running: bool
+start()
+stop()
+_handle_step_finish_report()
@@ -262,13 +266,13 @@ classDiagram
+_handle_material_change_report()
+_handle_error_handling_report()
}
%% 具体实现类
class BioyondWorkstation {
+bioyond_config: Dict
+workflow_mappings: Dict
+workflow_sequence: List
+post_init(ros_node)
+transfer_resource_to_another()
+resource_tree_add(resources)
@@ -276,25 +280,25 @@ classDiagram
+get_all_workflows()
+get_bioyond_status()
}
class ProtocolNode {
+post_init(ros_node)
}
%% 核心关系
WorkstationBase o-- ROS2WorkstationNode : post_init关联
WorkstationBase o-- WorkstationHTTPService : 可选服务
%% 物料管理侧
WorkstationBase *-- Deck : deck
WorkstationBase *-- ResourceSynchronizer : 可选组合
ResourceSynchronizer <|-- BioyondResourceSynchronizer
%% 硬件接口侧
WorkstationBase o-- HardwareInterface : hardware_interface
HardwareInterface <|.. BioyondV1RPC : 实现
BioyondResourceSynchronizer --> BioyondV1RPC : 使用
%% 继承关系
BioyondWorkstation --|> WorkstationBase
ProtocolNode --|> WorkstationBase
@@ -312,49 +316,49 @@ sequenceDiagram
participant HW as HardwareInterface
participant ROS as ROS2WorkstationNode
participant HTTP as HTTPService
APP->>WS: 创建工作站实例(__init__)
WS->>DECK: 初始化PLR Deck
DECK->>DECK: 创建Warehouse等子资源
DECK-->>WS: Deck创建完成
WS->>HW: 创建硬件接口(如BioyondV1RPC)
HW->>HW: 建立连接(PLC/RPC/串口等)
HW-->>WS: 硬件接口就绪
WS->>SYNC: 创建ResourceSynchronizer(可选)
SYNC->>HW: 使用hardware_interface
SYNC->>SYNC: 初始化同步配置
SYNC-->>WS: 同步器创建完成
WS->>SYNC: sync_from_external()
SYNC->>HW: 查询外部物料系统
HW-->>SYNC: 返回物料数据
SYNC->>DECK: 转换并添加到Deck
SYNC-->>WS: 同步完成
Note over WS: __init__完成,等待ROS节点
APP->>ROS: 初始化ROS2WorkstationNode
ROS->>ROS: 初始化子设备(children)
ROS->>ROS: 创建Action客户端
ROS->>ROS: 设置硬件接口代理
ROS-->>APP: ROS节点就绪
APP->>WS: post_init(ros_node)
WS->>WS: self._ros_node = ros_node
WS->>ROS: update_resource([deck])
ROS->>ROS: 上传物料到云端
ROS-->>WS: 上传完成
WS->>HTTP: 创建WorkstationHTTPService(可选)
HTTP->>HTTP: 启动HTTP服务器线程
HTTP-->>WS: HTTP服务启动
WS-->>APP: 工作站完全就绪
```
## 4. 工作流执行时序图Protocol模式
## 4. 工作流执行时序图Protocol 模式)
```{mermaid}
sequenceDiagram
@@ -365,15 +369,15 @@ sequenceDiagram
participant DECK as PLR Deck
participant CLOUD as 云端资源管理
participant DEV as 子设备
CLIENT->>ROS: 发送Protocol Action请求
ROS->>ROS: execute_protocol回调
ROS->>ROS: 从Goal提取参数
ROS->>ROS: 调用protocol_steps_generator
ROS->>ROS: 生成action步骤列表
ROS->>WS: 更新workflow_status = RUNNING
loop 执行每个步骤
alt 调用子设备
ROS->>ROS: execute_single_action(device_id, action, params)
@@ -394,19 +398,19 @@ sequenceDiagram
end
WS-->>ROS: 返回结果
end
ROS->>DECK: 更新本地物料状态
DECK->>DECK: 修改PLR资源属性
end
ROS->>CLOUD: 同步物料到云端(可选)
CLOUD-->>ROS: 同步完成
ROS->>WS: 更新workflow_status = COMPLETED
ROS-->>CLIENT: 返回Protocol Result
```
## 5. HTTP报送处理时序图
## 5. HTTP 报送处理时序图
```{mermaid}
sequenceDiagram
@@ -416,25 +420,25 @@ sequenceDiagram
participant DECK as PLR Deck
participant SYNC as ResourceSynchronizer
participant CLOUD as 云端
EXT->>HTTP: POST /report/step_finish
HTTP->>HTTP: 解析请求数据
HTTP->>HTTP: 验证LIMS协议字段
HTTP->>WS: process_step_finish_report(request)
WS->>WS: 增加接收计数(_reports_received_count++)
WS->>WS: 记录步骤完成事件
WS->>DECK: 更新相关物料状态(可选)
DECK->>DECK: 修改PLR资源状态
WS->>WS: 保存报送记录到内存
WS-->>HTTP: 返回处理结果
HTTP->>HTTP: 构造HTTP响应
HTTP-->>EXT: 200 OK + acknowledgment_id
Note over EXT,CLOUD: 类似处理sample_finish, order_finish等报送
alt 物料变更报送
EXT->>HTTP: POST /report/material_change
HTTP->>WS: process_material_change_report(data)
@@ -459,7 +463,7 @@ sequenceDiagram
participant HW as HardwareInterface
participant HTTP as HTTPService
participant LOG as 日志系统
alt 设备错误(ROS Action失败)
DEV->>ROS: Action返回失败结果
ROS->>ROS: 记录错误信息
@@ -471,7 +475,7 @@ sequenceDiagram
WS->>WS: 记录错误历史
WS->>LOG: 记录错误日志
end
alt 关键错误需要停止
WS->>ROS: stop_workflow(emergency=True)
ROS->>ROS: 取消所有进行中的Action
@@ -483,44 +487,44 @@ sequenceDiagram
WS->>ROS: 触发重试逻辑(可选)
ROS->>DEV: 重新发送Action
end
WS-->>HTTP: 返回错误处理结果
HTTP-->>DEV: 200 OK + 处理状态
```
## 7. 典型工作站实现示例
### 7.1 Bioyond集成工作站实现
### 7.1 Bioyond 集成工作站实现
```python
class BioyondWorkstation(WorkstationBase):
def __init__(self, bioyond_config: Dict, deck: Deck, *args, **kwargs):
# 初始化deck
super().__init__(deck=deck, *args, **kwargs)
# 设置硬件接口为Bioyond RPC客户端
self.hardware_interface = BioyondV1RPC(bioyond_config)
# 创建资源同步器
self.resource_synchronizer = BioyondResourceSynchronizer(self)
# 从Bioyond同步物料到本地deck
self.resource_synchronizer.sync_from_external()
# 配置工作流
self.workflow_mappings = bioyond_config.get("workflow_mappings", {})
def post_init(self, ros_node: ROS2WorkstationNode):
"""ROS节点就绪后的初始化"""
self._ros_node = ros_node
# 上传deck(包括所有物料)到云端
ROS2DeviceNode.run_async_func(
self._ros_node.update_resource,
True,
self._ros_node.update_resource,
True,
resources=[self.deck]
)
def resource_tree_add(self, resources: List[ResourcePLR]):
"""添加物料并同步到Bioyond"""
for resource in resources:
@@ -533,24 +537,24 @@ class BioyondWorkstation(WorkstationBase):
```python
class ProtocolNode(WorkstationBase):
"""纯协议节点,不需要物料管理和外部通信"""
def __init__(self, deck: Optional[Deck] = None, *args, **kwargs):
super().__init__(deck=deck, *args, **kwargs)
# 不设置hardware_interface和resource_synchronizer
# 所有功能通过子设备协同完成
def post_init(self, ros_node: ROS2WorkstationNode):
self._ros_node = ros_node
# 不需要上传物料或其他初始化
```
### 7.3 PLC直接控制工作站
### 7.3 PLC 直接控制工作站
```python
class PLCWorkstation(WorkstationBase):
def __init__(self, plc_config: Dict, deck: Deck, *args, **kwargs):
super().__init__(deck=deck, *args, **kwargs)
# 设置硬件接口为Modbus客户端
from pymodbus.client import ModbusTcpClient
self.hardware_interface = ModbusTcpClient(
@@ -558,7 +562,7 @@ class PLCWorkstation(WorkstationBase):
port=plc_config["port"]
)
self.hardware_interface.connect()
# 定义支持的工作流
self.supported_workflows = {
"battery_assembly": WorkflowInfo(
@@ -570,49 +574,49 @@ class PLCWorkstation(WorkstationBase):
parameters_schema={"quantity": int, "model": str}
)
}
def execute_workflow(self, workflow_name: str, parameters: Dict):
"""通过PLC执行工作流"""
workflow_id = self._get_workflow_id(workflow_name)
# 写入PLC寄存器启动工作流
self.hardware_interface.write_register(100, workflow_id)
self.hardware_interface.write_register(101, parameters["quantity"])
self.current_workflow_status = WorkflowStatus.RUNNING
return True
```
## 8. 核心接口说明
### 8.1 WorkstationBase核心属性
### 8.1 WorkstationBase 核心属性
| 属性 | 类型 | 说明 |
| --------------------------- | ----------------------- | ----------------------------- |
| `_ros_node` | ROS2WorkstationNode | ROS节点引用由post_init设置 |
| `deck` | Deck | PyLabRobot Deck本地物料系统 |
| `plr_resources` | Dict[str, PLRResource] | 物料资源映射 |
| `resource_synchronizer` | ResourceSynchronizer | 外部物料同步器(可选) |
| `hardware_interface` | Union[Any, str] | 硬件接口或代理字符串 |
| `current_workflow_status` | WorkflowStatus | 当前工作流状态 |
| `supported_workflows` | Dict[str, WorkflowInfo] | 支持的工作流定义 |
| 属性 | 类型 | 说明 |
| ------------------------- | ----------------------- | ------------------------------- |
| `_ros_node` | ROS2WorkstationNode | ROS 节点引用,由 post_init 设置 |
| `deck` | Deck | PyLabRobot Deck本地物料系统 |
| `plr_resources` | Dict[str, PLRResource] | 物料资源映射 |
| `resource_synchronizer` | ResourceSynchronizer | 外部物料同步器(可选) |
| `hardware_interface` | Union[Any, str] | 硬件接口或代理字符串 |
| `current_workflow_status` | WorkflowStatus | 当前工作流状态 |
| `supported_workflows` | Dict[str, WorkflowInfo] | 支持的工作流定义 |
### 8.2 必须实现的方法
- `post_init(ros_node)`: ROS节点就绪后的初始化必须实现
- `post_init(ros_node)`: ROS 节点就绪后的初始化,必须实现
### 8.3 硬件接口相关方法
- `set_hardware_interface(interface)`: 设置硬件接口
- `call_device_method(method, *args, **kwargs)`: 统一设备方法调用
- 支持直接模式: 直接调用hardware_interface的方法
- 支持代理模式: hardware_interface="proxy:device_id"通过ROS转发
- 支持直接模式: 直接调用 hardware_interface 的方法
- 支持代理模式: hardware_interface="proxy:device_id"通过 ROS 转发
- `get_device_status()`: 获取设备状态
- `is_device_available()`: 检查设备可用性
### 8.4 物料管理方法
- `get_deck()`: 获取PLR Deck
- `get_deck()`: 获取 PLR Deck
- `get_all_resources()`: 获取所有物料
- `find_resource_by_name(name)`: 按名称查找物料
- `find_resources_by_type(type)`: 按类型查找物料
@@ -626,7 +630,7 @@ class PLCWorkstation(WorkstationBase):
- `is_busy`: 检查是否忙碌(属性)
- `workflow_runtime`: 获取运行时间(属性)
### 8.6 可选的HTTP报送处理方法
### 8.6 可选的 HTTP 报送处理方法
- `process_step_finish_report()`: 步骤完成处理
- `process_sample_finish_report()`: 样本完成处理
@@ -634,10 +638,10 @@ class PLCWorkstation(WorkstationBase):
- `process_material_change_report()`: 物料变更处理
- `handle_external_error()`: 错误处理
### 8.7 ROS2WorkstationNode核心方法
### 8.7 ROS2WorkstationNode 核心方法
- `initialize_device(device_id, config)`: 初始化子设备
- `create_ros_action_server(action_name, mapping)`: 创建Action服务器
- `create_ros_action_server(action_name, mapping)`: 创建 Action 服务器
- `execute_single_action(device_id, action, kwargs)`: 执行单个动作
- `update_resource(resources)`: 同步物料到云端
- `transfer_resource_to_another(...)`: 跨设备物料转移
@@ -694,7 +698,7 @@ workstation = BioyondWorkstation(
"config": {...}
},
"gripper_1": {
"type": "device",
"type": "device",
"driver": "RobotiqGripperDriver",
"communication": "io_modbus_1",
"config": {...}
@@ -716,7 +720,7 @@ workstation = BioyondWorkstation(
}
```
### 9.3 HTTP服务配置
### 9.3 HTTP 服务配置
```python
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
@@ -737,31 +741,31 @@ http_service.start()
### 10.1 清晰的职责分离
- **WorkstationBase**: 负责物料管理(deck)、硬件接口(hardware_interface)、工作流状态管理
- **ROS2WorkstationNode**: 负责子设备管理、Protocol执行、云端物料同步
- **ResourceSynchronizer**: 可选的外部物料系统同步(如Bioyond)
- **WorkstationHTTPService**: 可选的HTTP报送接收服务
- **ROS2WorkstationNode**: 负责子设备管理、Protocol 执行、云端物料同步
- **ResourceSynchronizer**: 可选的外部物料系统同步(如 Bioyond)
- **WorkstationHTTPService**: 可选的 HTTP 报送接收服务
### 10.2 灵活的硬件接口模式
1. **直接模式**: hardware_interface是具体对象(如BioyondV1RPC、ModbusClient)
2. **代理模式**: hardware_interface="proxy:device_id"通过ROS节点转发到子设备
1. **直接模式**: hardware_interface 是具体对象(如 BioyondV1RPC、ModbusClient)
2. **代理模式**: hardware_interface="proxy:device_id",通过 ROS 节点转发到子设备
3. **混合模式**: 工作站有自己的接口,同时管理多个子设备
### 10.3 统一的物料系统
- 基于PyLabRobot Deck的标准化物料表示
- 通过ResourceSynchronizer实现与外部系统(如Bioyond、LIMS)的双向同步
- 通过ROS2WorkstationNode实现与云端的物料状态同步
- 基于 PyLabRobot Deck 的标准化物料表示
- 通过 ResourceSynchronizer 实现与外部系统(如 Bioyond、LIMS)的双向同步
- 通过 ROS2WorkstationNode 实现与云端的物料状态同步
### 10.4 Protocol驱动的工作流
### 10.4 Protocol 驱动的工作流
- ROS2WorkstationNode负责Protocol的执行和步骤管理
- 支持子设备协同(通过Action Client调用)
- 支持工作站直接控制(通过hardware_interface)
- ROS2WorkstationNode 负责 Protocol 的执行和步骤管理
- 支持子设备协同(通过 Action Client 调用)
- 支持工作站直接控制(通过 hardware_interface)
### 10.5 可选的HTTP报送服务
### 10.5 可选的 HTTP 报送服务
- 基于LIMS协议规范的统一报送接口
- 基于 LIMS 协议规范的统一报送接口
- 支持步骤完成、样本完成、任务完成、物料变更等多种报送类型
- 与工作站解耦,可独立启停

View File

@@ -0,0 +1,334 @@
# HTTP API 指南
本文档介绍如何通过 HTTP API 与 Uni-Lab-OS 进行交互,包括查询设备、提交任务和获取结果。
## 概述
Uni-Lab-OS 提供 RESTful HTTP API允许外部系统通过标准 HTTP 请求控制实验室设备。API 基于 FastAPI 构建,默认运行在 `http://localhost:8002`
### 基础信息
- **Base URL**: `http://localhost:8002/api/v1`
- **Content-Type**: `application/json`
- **响应格式**: JSON
### 通用响应结构
```json
{
"code": 0,
"data": { ... },
"message": "success"
}
```
| 字段 | 类型 | 说明 |
| --------- | ------ | ------------------ |
| `code` | int | 状态码0 表示成功 |
| `data` | object | 响应数据 |
| `message` | string | 响应消息 |
## 快速开始
以下是一个完整的工作流示例:查询设备 → 获取动作 → 提交任务 → 获取结果。
### 步骤 1: 获取在线设备
```bash
curl -X GET "http://localhost:8002/api/v1/online-devices"
```
**响应示例**:
```json
{
"code": 0,
"data": {
"online_devices": {
"host_node": {
"device_key": "/host_node",
"namespace": "",
"machine_name": "本地",
"uuid": "xxx-xxx-xxx",
"node_name": "host_node"
}
},
"total_count": 1,
"timestamp": 1732612345.123
},
"message": "success"
}
```
### 步骤 2: 获取设备可用动作
```bash
curl -X GET "http://localhost:8002/api/v1/devices/host_node/actions"
```
**响应示例**:
```json
{
"code": 0,
"data": {
"device_id": "host_node",
"actions": {
"test_latency": {
"type_name": "unilabos_msgs.action._empty_in.EmptyIn",
"type_name_convert": "unilabos_msgs/action/_empty_in/EmptyIn",
"action_path": "/devices/host_node/test_latency",
"goal_info": "{}",
"is_busy": false,
"current_job_id": null
},
"create_resource": {
"type_name": "unilabos_msgs.action._resource_create_from_outer_easy.ResourceCreateFromOuterEasy",
"action_path": "/devices/host_node/create_resource",
"goal_info": "{res_id: '', device_id: '', class_name: '', ...}",
"is_busy": false,
"current_job_id": null
}
},
"action_count": 5
},
"message": "success"
}
```
**动作状态字段说明**:
| 字段 | 说明 |
| ---------------- | ----------------------------- |
| `type_name` | 动作类型的完整名称 |
| `action_path` | ROS2 动作路径 |
| `goal_info` | 动作参数模板 |
| `is_busy` | 动作是否正在执行 |
| `current_job_id` | 当前执行的任务 ID如果繁忙 |
### 步骤 3: 提交任务
```bash
curl -X POST "http://localhost:8002/api/v1/job/add" \
-H "Content-Type: application/json" \
-d '{"device_id":"host_node","action":"test_latency","action_args":{}}'
```
**请求体**:
```json
{
"device_id": "host_node",
"action": "test_latency",
"action_args": {}
}
```
**请求参数说明**:
| 字段 | 类型 | 必填 | 说明 |
| ------------- | ------ | ---- | ---------------------------------- |
| `device_id` | string | ✓ | 目标设备 ID |
| `action` | string | ✓ | 动作名称 |
| `action_args` | object | ✓ | 动作参数(根据动作类型不同而变化) |
**响应示例**:
```json
{
"code": 0,
"data": {
"jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd",
"status": 1,
"result": {}
},
"message": "success"
}
```
**任务状态码**:
| 状态码 | 含义 | 说明 |
| ------ | --------- | ------------------------------ |
| 0 | UNKNOWN | 未知状态 |
| 1 | ACCEPTED | 任务已接受,等待执行 |
| 2 | EXECUTING | 任务执行中 |
| 3 | CANCELING | 任务取消中 |
| 4 | SUCCEEDED | 任务成功完成 |
| 5 | CANCELED | 任务已取消 |
| 6 | ABORTED | 任务中止(设备繁忙或执行失败) |
### 步骤 4: 查询任务状态和结果
```bash
curl -X GET "http://localhost:8002/api/v1/job/b6acb586-733a-42ab-9f73-55c9a52aa8bd/status"
```
**响应示例(执行中)**:
```json
{
"code": 0,
"data": {
"jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd",
"status": 2,
"result": {}
},
"message": "success"
}
```
**响应示例(执行完成)**:
```json
{
"code": 0,
"data": {
"jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd",
"status": 4,
"result": {
"error": "",
"suc": true,
"return_value": {
"avg_rtt_ms": 103.99,
"avg_time_diff_ms": 7181.55,
"max_time_error_ms": 7210.57,
"task_delay_ms": -1,
"raw_delay_ms": 33.19,
"test_count": 5,
"status": "success"
}
}
},
"message": "success"
}
```
> **注意**: 任务结果在首次查询后会被自动删除,请确保保存返回的结果数据。
## API 端点列表
### 设备相关
| 端点 | 方法 | 说明 |
| ---------------------------------------------------------- | ---- | ---------------------- |
| `/api/v1/online-devices` | GET | 获取在线设备列表 |
| `/api/v1/devices` | GET | 获取设备配置 |
| `/api/v1/devices/{device_id}/actions` | GET | 获取指定设备的可用动作 |
| `/api/v1/devices/{device_id}/actions/{action_name}/schema` | GET | 获取动作参数 Schema |
| `/api/v1/actions` | GET | 获取所有设备的可用动作 |
### 任务相关
| 端点 | 方法 | 说明 |
| ----------------------------- | ---- | ------------------ |
| `/api/v1/job/add` | POST | 提交新任务 |
| `/api/v1/job/{job_id}/status` | GET | 查询任务状态和结果 |
### 资源相关
| 端点 | 方法 | 说明 |
| ------------------- | ---- | ------------ |
| `/api/v1/resources` | GET | 获取资源列表 |
## 常见动作示例
### test_latency - 延迟测试
测试系统延迟,无需参数。
```bash
curl -X POST "http://localhost:8002/api/v1/job/add" \
-H "Content-Type: application/json" \
-d '{"device_id":"host_node","action":"test_latency","action_args":{}}'
```
### create_resource - 创建资源
在设备上创建新资源。
```bash
curl -X POST "http://localhost:8002/api/v1/job/add" \
-H "Content-Type: application/json" \
-d '{
"device_id": "host_node",
"action": "create_resource",
"action_args": {
"res_id": "my_plate",
"device_id": "host_node",
"class_name": "Plate",
"parent": "deck",
"bind_locations": {"x": 0, "y": 0, "z": 0}
}
}'
```
## 错误处理
### 设备繁忙
当设备正在执行其他任务时,提交新任务会返回 `status: 6`ABORTED
```json
{
"code": 0,
"data": {
"jobId": "xxx",
"status": 6,
"result": {}
},
"message": "success"
}
```
此时应等待当前任务完成后重试,或使用 `/devices/{device_id}/actions` 检查动作的 `is_busy` 状态。
### 参数错误
```json
{
"code": 2002,
"data": { ... },
"message": "device_id is required"
}
```
## 轮询策略
推荐的任务状态轮询策略:
```python
import requests
import time
def wait_for_job(job_id, timeout=60, interval=0.5):
"""等待任务完成并返回结果"""
start_time = time.time()
while time.time() - start_time < timeout:
response = requests.get(f"http://localhost:8002/api/v1/job/{job_id}/status")
data = response.json()["data"]
status = data["status"]
if status in (4, 5, 6): # SUCCEEDED, CANCELED, ABORTED
return data
time.sleep(interval)
raise TimeoutError(f"Job {job_id} did not complete within {timeout} seconds")
# 使用示例
response = requests.post(
"http://localhost:8002/api/v1/job/add",
json={"device_id": "host_node", "action": "test_latency", "action_args": {}}
)
job_id = response.json()["data"]["jobId"]
result = wait_for_job(job_id)
print(result)
```
## 相关文档
- [设备注册指南](add_device.md)
- [动作定义指南](add_action.md)
- [网络架构概述](networking_overview.md)

View File

@@ -0,0 +1,594 @@
# 组网部署与主从模式配置
本文档介绍 Uni-Lab-OS 的组网架构、部署方式和主从模式的详细配置。
## 目录
- [架构概览](#架构概览)
- [节点类型](#节点类型)
- [通信机制](#通信机制)
- [典型拓扑](#典型拓扑)
- [主从模式配置](#主从模式配置)
- [网络配置](#网络配置)
- [示例:多房间部署](#示例多房间部署)
- [故障处理](#故障处理)
- [监控和维护](#监控和维护)
---
## 架构概览
Uni-Lab-OS 支持多种部署模式:
```
┌──────────────────────────────────────────────┐
│ Cloud Platform/Self-hosted Platform │
│ uni-lab.bohrium.com │
│ (Resource Management, Task Scheduling, │
│ Monitoring) │
└────────────────────┬─────────────────────────┘
│ WebSocket / HTTP
┌──────────┴──────────┐
│ │
┌────▼─────┐ ┌────▼─────┐
│ Master │◄──ROS2──►│ Slave │
│ Node │ │ Node │
│ (Host) │ │ (Slave) │
└────┬─────┘ └────┬─────┘
│ │
┌────┴────┐ ┌────┴────┐
│ Device A│ │ Device B│
│ Device C│ │ Device D│
└─────────┘ └─────────┘
```
---
## 节点类型
### 主节点Host Node
**功能**:
- 创建和管理全局资源
- 提供 host_node 服务
- 连接云端平台
- 协调多个从节点
- 提供 Web 管理界面
**启动命令**:
```bash
unilab --ak your_ak --sk your_sk -g host_devices.json
```
### 从节点Slave Node
**功能**:
- 管理本地设备
- 不连接云端(可选)
- 向主节点注册
- 执行分配的任务
**启动命令**:
```bash
unilab --ak your_ak --sk your_sk -g slave_devices.json --is_slave
```
---
## 通信机制
### ROS2 通信
**用途**: 节点间实时通信
**通信方式**:
- **Topic**: 状态广播(设备状态、传感器数据)
- **Service**: 同步请求(资源查询、配置获取)
- **Action**: 异步任务(设备操作、长时间运行)
**示例**:
```bash
# 查看ROS2节点
ros2 node list
# 查看topic
ros2 topic list
# 查看action
ros2 action list
```
### WebSocket 通信
**用途**: 主节点与云端通信
**特点**:
- 实时双向通信
- 自动重连
- 心跳保持
**配置**:
```python
# local_config.py
BasicConfig.ak = "your_ak"
BasicConfig.sk = "your_sk"
```
---
## 典型拓扑
### 单节点模式
**适用场景**: 小型实验室、开发测试
```
┌──────────────────┐
│ Uni-Lab Node │
│ ┌────────────┐ │
│ │ Device A │ │
│ │ Device B │ │
│ │ Device C │ │
│ └────────────┘ │
└──────────────────┘
```
**优点**:
- 配置简单
- 无网络延迟
- 适合快速原型
**启动**:
```bash
unilab --ak your_ak --sk your_sk -g all_devices.json
```
### 主从模式
**适用场景**: 多房间、分布式设备
```
┌─────────────┐ ┌──────────────┐
│ Master Node │◄────►│ Slave Node 1 │
│ Coordinator │ │ Liquid │
│ Web UI │ │ Handling │
└──────┬──────┘ └──────────────┘
│ ┌──────────────┐
└────────────►│ Slave Node 2 │
│ Analytical │
│ (NMR/GC) │
└──────────────┘
```
**优点**:
- 物理分隔
- 独立故障域
- 易于扩展
**适用场景**:
- 设备物理位置分散
- 不同房间的设备
- 需要独立故障域
- 分阶段扩展系统
**主节点**:
```bash
unilab --ak your_ak --sk your_sk -g host.json
```
**从节点**:
```bash
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave
unilab --ak your_ak --sk your_sk -g slave2.json --is_slave --port 8003
```
### 云端集成模式
**适用场景**: 远程监控、多实验室协作
```
Cloud Platform
┌───────┴────────┐
│ │
Laboratory A Laboratory B
(Master Node) (Master Node)
```
**优点**:
- 远程访问
- 数据同步
- 任务调度
**启动**:
```bash
# 实验室A
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
# 实验室B
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
```
---
## 主从模式配置
### 主节点配置
#### 1. 创建主节点设备图
`host.json`:
```json
{
"nodes": [],
"links": []
}
```
#### 2. 启动主节点
```bash
# 基本启动
unilab --ak your_ak --sk your_sk -g host.json
# 带云端集成
unilab --ak your_ak --sk your_sk -g host.json --upload_registry
# 指定端口
unilab --ak your_ak --sk your_sk -g host.json --port 8002
```
#### 3. 验证主节点
```bash
# 检查ROS2节点
ros2 node list
# 应该看到 /host_node
# 检查服务
ros2 service list | grep host_node
# Web界面
# 访问 http://localhost:8002
```
### 从节点配置
#### 1. 创建从节点设备图
`slave1.json`:
```json
{
"nodes": [
{
"id": "liquid_handler_1",
"name": "液体处理工作站",
"type": "device",
"class": "liquid_handler",
"config": {
"simulation": false
}
}
],
"links": []
}
```
#### 2. 启动从节点
```bash
# 基本从节点启动
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave
# 指定不同端口(如果多个从节点在同一台机器)
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave --port 8003
# 跳过等待主节点(独立测试)
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave --slave_no_host
```
#### 3. 验证从节点
```bash
# 检查节点连接
ros2 node list
# 检查设备状态
ros2 topic echo /liquid_handler_1/status
```
### 跨节点通信
#### 资源访问
主节点可以访问从节点的资源:
```bash
# 在主节点或其他节点调用从节点设备
ros2 action send_goal /liquid_handler_1/transfer_liquid \
unilabos_msgs/action/TransferLiquid \
"{source: {...}, target: {...}, volume: 100.0}"
```
#### 状态监控
主节点监控所有从节点状态:
```bash
# 订阅从节点状态
ros2 topic echo /liquid_handler_1/status
# 查看所有设备状态
ros2 topic list | grep status
```
---
## 网络配置
### ROS2 DDS 配置
确保主从节点在同一网络:
```bash
# 检查网络可达性
ping <slave_node_ip>
# 设置ROS_DOMAIN_ID可选用于隔离
export ROS_DOMAIN_ID=42
```
### 防火墙配置
**建议做法**
为了确保 ROS2 DDS 通信正常建议直接关闭防火墙而不是配置特定端口。ROS2 使用动态端口范围,配置特定端口可能导致通信问题。
**Linux**:
```bash
# 关闭防火墙
sudo ufw disable
# 或者临时停止防火墙
sudo systemctl stop ufw
```
**Windows**:
```powershell
# 在Windows安全中心关闭防火墙
# 控制面板 -> 系统和安全 -> Windows Defender 防火墙 -> 启用或关闭Windows Defender防火墙
```
### 验证网络连通性
在配置完成后,使用 ROS2 自带的 demo 节点来验证跨节点通信是否正常:
**在主节点机器上**(激活 unilab 环境后):
```bash
# 启动talker
ros2 run demo_nodes_cpp talker
# 同时在另一个终端启动listener
ros2 run demo_nodes_cpp listener
```
**在从节点机器上**(激活 unilab 环境后):
```bash
# 启动talker
ros2 run demo_nodes_cpp talker
# 同时在另一个终端启动listener
ros2 run demo_nodes_cpp listener
```
**注意**:必须在两台机器上**互相启动** talker 和 listener否则可能出现只能收不能发的单向通信问题。
**预期结果**
- 每台机器的 listener 应该能同时接收到本地和远程 talker 发送的消息
- 如果只能看到本地消息,说明网络配置有问题
- 如果两台机器都能互相收发消息,则组网配置正确
### 本地网络要求
**ROS2 通信**:
- 同一局域网或 VPN
- 端口:默认 DDS 端口7400-7500
- 组播支持(或配置 unicast
**检查连通性**:
```bash
# Ping测试
ping <target_ip>
# ROS2节点发现
ros2 node list
ros2 daemon stop && ros2 daemon start
```
### 云端连接
**要求**:
- HTTPS (443)
- WebSocket 支持
- 稳定的互联网连接
**测试连接**:
```bash
# 测试云端连接
curl https://uni-lab.bohrium.com/api/v1/health
# 测试WebSocket
# 启动Uni-Lab后查看日志
```
---
## 示例:多房间部署
### 场景描述
- **房间 A**: 主控室,有 Web 界面
- **房间 B**: 液体处理室
- **房间 C**: 分析仪器室
### 房间 A - 主节点
```bash
# host.json
unilab --ak your_ak --sk your_sk -g host.json --port 8002
```
### 房间 B - 从节点 1
```bash
# liquid_handler.json
unilab --ak your_ak --sk your_sk -g liquid_handler.json --is_slave --port 8003
```
### 房间 C - 从节点 2
```bash
# analytical.json
unilab --ak your_ak --sk your_sk -g analytical.json --is_slave --port 8004
```
---
## 故障处理
### 节点离线
**检测**:
```bash
ros2 node list # 查看在线节点
```
**处理**:
1. 检查网络连接
2. 重启节点
3. 检查日志
### 从节点无法连接主节点
1. 检查网络:
```bash
ping <host_ip>
```
2. 检查 ROS_DOMAIN_ID
```bash
echo $ROS_DOMAIN_ID
```
3. 使用`--slave_no_host`测试:
```bash
unilab --ak your_ak --sk your_sk -g slave.json --is_slave --slave_no_host
```
### 通信延迟
**排查**:
```bash
# 网络延迟
ping <node_ip>
# ROS2话题延迟
ros2 topic hz /device_status
ros2 topic bw /device_status
```
**优化**:
- 减少发布频率
- 使用 QoS 配置
- 优化网络带宽
### 数据同步失败
**检查**:
```bash
# 查看日志
tail -f unilabos_data/logs/unilab.log | grep sync
```
**解决**:
- 检查云端连接
- 验证 AK/SK
- 手动触发同步
### 资源不可见
检查资源注册:
```bash
ros2 service call /host_node/resource_list \
unilabos_msgs/srv/ResourceList
```
---
## 监控和维护
### 节点状态监控
```bash
# 查看所有节点
ros2 node list
# 查看话题
ros2 topic list
```
---
## 相关文档
- [最佳实践指南](../user_guide/best_practice.md) - 完整的实验室搭建流程
- [安装指南](../user_guide/installation.md) - 环境安装步骤
- [启动参数详解](../user_guide/launch.md) - 启动参数说明
- [添加设备驱动](add_device.md) - 自定义设备开发
- [工作站架构](workstation_architecture.md) - 复杂工作站搭建
---
## 参考资料
- [ROS2 网络配置](https://docs.ros.org/en/humble/Tutorials/Advanced/Networking.html)
- [DDS 配置](https://fast-dds.docs.eprosima.com/)
- Uni-Lab 云平台文档

View File

@@ -1,9 +1,23 @@
# Uni-Lab 项目文档
# Uni-Lab-OS 项目文档
欢迎来到项目文档的首页!
Uni-Lab-OS 是一个开源的实验室自动化操作系统,提供统一的设备接口、工作流管理和分布式部署能力。
```{toctree}
:maxdepth: 3
intro.md
```
## 开发者指南
```{toctree}
:maxdepth: 2
developer_guide/http_api.md
developer_guide/networking_overview.md
developer_guide/add_device.md
developer_guide/add_action.md
developer_guide/add_registry.md
developer_guide/add_yaml.md
developer_guide/action_includes.md
```

View File

@@ -10,35 +10,51 @@ concepts/01-communication-instruction.md
concepts/02-topology-and-chemputer-compile.md
```
## **用户指南**
## 用户指南
本指南将带你了解如何使用项目的功能
快速上手、系统配置与使用说明
```{toctree}
:maxdepth: 2
user_guide/best_practice.md
user_guide/installation.md
user_guide/configuration.md
user_guide/launch.md
user_guide/graph_files.md
boot_examples/index.md
```
## 进阶配置
高级配置和系统管理。
```{toctree}
:maxdepth: 2
advanced_usage/configuration.md
advanced_usage/working_directory.md
```
## 开发者指南
```{toctree}
设备开发、系统扩展与架构说明。
```{toctree}
:maxdepth: 2
developer_guide/device_driver
developer_guide/add_device
developer_guide/add_action
developer_guide/actions
developer_guide/workstation_architecture
developer_guide/add_protocol
developer_guide/add_batteryPLC
developer_guide/materials_tutorial
developer_guide/materials_construction_guide
developer_guide/networking_overview.md
developer_guide/add_device.md
developer_guide/add_old_device.md
developer_guide/add_registry.md
developer_guide/add_yaml.md
developer_guide/add_action.md
developer_guide/actions.md
developer_guide/action_includes.md
developer_guide/add_protocol.md
developer_guide/examples/workstation_architecture.md
developer_guide/examples/materials_construction_guide.md
developer_guide/examples/materials_tutorial.md
developer_guide/examples/battery_plc_workstation.md
```
## 接口文档

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 262 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,442 +0,0 @@
# Uni-Lab 配置指南
Uni-Lab 支持通过 Python 配置文件进行灵活的系统配置。本指南将帮助您理解配置选项并设置您的 Uni-Lab 环境。
## 配置文件格式
Uni-Lab 支持 Python 格式的配置文件,它比 YAML 或 JSON 提供更多的灵活性,包括支持注释、条件逻辑和复杂数据结构。
### 默认配置示例
首次使用时,系统会自动创建一个基础配置文件 `local_config.py`
```python
# unilabos的配置文件
class BasicConfig:
ak = "" # 实验室网页给您提供的ak代码您可以在配置文件中指定也可以通过运行unilabos时以 --ak 传入,优先按照传入参数解析
sk = "" # 实验室网页给您提供的sk代码您可以在配置文件中指定也可以通过运行unilabos时以 --sk 传入,优先按照传入参数解析
# WebSocket配置一般无需调整
class WSConfig:
reconnect_interval = 5 # 重连间隔(秒)
max_reconnect_attempts = 999 # 最大重连次数
ping_interval = 30 # ping间隔
```
您可以进入实验室点击左下角的头像在实验室详情中获取所在实验室的ak sk
![copy_aksk.gif](image/copy_aksk.gif)
### 完整配置示例
您可以根据需要添加更多配置选项:
```python
#!/usr/bin/env python
# coding=utf-8
"""Uni-Lab 配置文件"""
# 基础配置
class BasicConfig:
ak = "your_access_key" # 实验室访问密钥
sk = "your_secret_key" # 实验室私钥
working_dir = "" # 工作目录(通常自动设置)
config_path = "" # 配置文件路径(自动设置)
is_host_mode = True # 是否为主站模式
slave_no_host = False # 从站模式下是否跳过等待主机服务
upload_registry = False # 是否上传注册表
machine_name = "undefined" # 机器名称(自动获取)
vis_2d_enable = False # 是否启用2D可视化
enable_resource_load = True # 是否启用资源加载
communication_protocol = "websocket" # 通信协议
# WebSocket配置
class WSConfig:
reconnect_interval = 5 # 重连间隔(秒)
max_reconnect_attempts = 999 # 最大重连次数
ping_interval = 30 # ping间隔
# OSS上传配置
class OSSUploadConfig:
api_host = "" # API主机地址
authorization = "" # 授权信息
init_endpoint = "" # 初始化端点
complete_endpoint = "" # 完成端点
max_retries = 3 # 最大重试次数
# HTTP配置
class HTTPConfig:
remote_addr = "http://127.0.0.1:48197/api/v1" # 远程地址
# ROS配置
class ROSConfig:
modules = [
"std_msgs.msg",
"geometry_msgs.msg",
"control_msgs.msg",
"control_msgs.action",
"nav2_msgs.action",
"unilabos_msgs.msg",
"unilabos_msgs.action",
] # 需要加载的ROS模块
```
## 命令行参数覆盖配置
Uni-Lab 允许通过命令行参数覆盖配置文件中的设置,提供更灵活的配置方式。命令行参数的优先级高于配置文件。
### 支持命令行覆盖的配置项
以下配置项可以通过命令行参数进行覆盖:
| 配置类 | 配置字段 | 命令行参数 | 说明 |
| ------------- | ----------------- | ------------------- | -------------------------------- |
| `BasicConfig` | `ak` | `--ak` | 实验室访问密钥 |
| `BasicConfig` | `sk` | `--sk` | 实验室私钥 |
| `BasicConfig` | `working_dir` | `--working_dir` | 工作目录路径 |
| `BasicConfig` | `is_host_mode` | `--is_slave` | 主站模式(参数为从站模式,取反) |
| `BasicConfig` | `slave_no_host` | `--slave_no_host` | 从站模式下跳过等待主机服务 |
| `BasicConfig` | `upload_registry` | `--upload_registry` | 启动时上传注册表信息 |
| `BasicConfig` | `vis_2d_enable` | `--2d_vis` | 启用 2D 可视化 |
| `HTTPConfig` | `remote_addr` | `--addr` | 远程服务地址 |
### 特殊命令行参数
除了直接覆盖配置项的参数外,还有一些特殊的命令行参数:
| 参数 | 说明 |
| ------------------- | ------------------------------------ |
| `--config` | 指定配置文件路径 |
| `--port` | Web 服务端口(不影响配置文件) |
| `--disable_browser` | 禁用自动打开浏览器(不影响配置文件) |
| `--visual` | 可视化工具选择(不影响配置文件) |
| `--skip_env_check` | 跳过环境检查(不影响配置文件) |
### 配置优先级
配置项的生效优先级从高到低为:
1. **命令行参数**:最高优先级
2. **环境变量**:中等优先级
3. **配置文件**:基础优先级
### 使用示例
```bash
# 通过命令行覆盖认证信息
unilab --ak "new_access_key" --sk "new_secret_key"
# 覆盖服务器地址
unilab --addr "https://custom.server.com/api/v1"
# 启用从站模式并跳过等待主机
unilab --is_slave --slave_no_host
# 启用上传注册表和2D可视化
unilab --upload_registry --2d_vis
# 组合使用多个覆盖参数
unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis
```
### 预设环境地址
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL
- `test``https://uni-lab.test.bohrium.com/api/v1`
- `uat``https://uni-lab.uat.bohrium.com/api/v1`
- `local``http://127.0.0.1:48197/api/v1`
- 其他值 → 直接使用作为完整 URL
## 配置选项详解
### 基础配置 (BasicConfig)
基础配置包含了系统运行的核心参数:
| 参数 | 类型 | 默认值 | 说明 |
| ------------------------ | ---- | ------------- | ------------------------------------------ |
| `ak` | str | `""` | 实验室访问密钥(必需) |
| `sk` | str | `""` | 实验室私钥(必需) |
| `working_dir` | str | `""` | 工作目录,通常自动设置 |
| `is_host_mode` | bool | `True` | 是否为主站模式 |
| `slave_no_host` | bool | `False` | 从站模式下是否跳过等待主机服务 |
| `upload_registry` | bool | `False` | 启动时是否上传注册表信息 |
| `machine_name` | str | `"undefined"` | 机器名称,自动从 hostname 获取(不可配置) |
| `vis_2d_enable` | bool | `False` | 是否启用 2D 可视化 |
| `communication_protocol` | str | `"websocket"` | 通信协议,固定为 websocket |
#### 认证配置
`ak``sk` 是必需的认证参数:
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
2. **配置方式**
- **命令行参数**`--ak "your_key" --sk "your_secret"`(最高优先级)
- **配置文件**:在 `BasicConfig` 类中设置
- **环境变量**`UNILABOS_BASICCONFIG_AK``UNILABOS_BASICCONFIG_SK`
3. **优先级顺序**:命令行参数 > 环境变量 > 配置文件
4. **安全注意**:请妥善保管您的密钥信息
**推荐做法**
- 开发环境:使用配置文件
- 生产环境:使用环境变量或命令行参数
- 临时测试:使用命令行参数
### WebSocket 配置 (WSConfig)
WebSocket 是 Uni-Lab 的主要通信方式:
| 参数 | 类型 | 默认值 | 说明 |
| ------------------------ | ---- | ------ | ------------------ |
| `reconnect_interval` | int | `5` | 断线重连间隔(秒) |
| `max_reconnect_attempts` | int | `999` | 最大重连次数 |
| `ping_interval` | int | `30` | 心跳检测间隔(秒) |
### HTTP 配置 (HTTPConfig)
HTTP 客户端配置用于与云端服务通信:
| 参数 | 类型 | 默认值 | 说明 |
| ------------- | ---- | --------------------------------- | ------------ |
| `remote_addr` | str | `"http://127.0.0.1:48197/api/v1"` | 远程服务地址 |
**预设环境地址**
- 生产环境:`https://uni-lab.bohrium.com/api/v1`
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
- 本地环境:`http://127.0.0.1:48197/api/v1`
### ROS 配置 (ROSConfig)
配置 ROS 消息转换器需要加载的模块:
```python
class ROSConfig:
modules = [
"std_msgs.msg", # 标准消息类型
"geometry_msgs.msg", # 几何消息类型
"control_msgs.msg", # 控制消息类型
"control_msgs.action", # 控制动作类型
"nav2_msgs.action", # 导航动作类型
"unilabos_msgs.msg", # UniLab 自定义消息类型
"unilabos_msgs.action", # UniLab 自定义动作类型
]
```
您可以根据实际使用的设备和功能添加其他 ROS 模块。
### OSS 上传配置 (OSSUploadConfig)
对象存储服务配置,用于文件上传功能:
| 参数 | 类型 | 默认值 | 说明 |
| ------------------- | ---- | ------ | -------------------- |
| `api_host` | str | `""` | OSS API 主机地址 |
| `authorization` | str | `""` | 授权认证信息 |
| `init_endpoint` | str | `""` | 上传初始化端点 |
| `complete_endpoint` | str | `""` | 上传完成端点 |
| `max_retries` | int | `3` | 上传失败最大重试次数 |
## 环境变量支持
Uni-Lab 支持通过环境变量覆盖配置文件中的设置。环境变量格式为:
```
UNILABOS_{配置类名}_{字段名}
```
### 环境变量示例
```bash
# 设置基础配置
export UNILABOS_BASICCONFIG_AK="your_access_key"
export UNILABOS_BASICCONFIG_SK="your_secret_key"
export UNILABOS_BASICCONFIG_IS_HOST_MODE="true"
# 设置WebSocket配置
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
# 设置HTTP配置
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.bohrium.com/api/v1"
```
### 环境变量类型转换
- **布尔值**`"true"`, `"1"`, `"yes"``True`;其他 → `False`
- **整数**:自动转换为 `int` 类型
- **浮点数**:自动转换为 `float` 类型
- **字符串**:保持原值
## 配置文件使用方法
### 1. 指定配置文件启动
```bash
# 使用指定配置文件启动
unilab --config /path/to/your/config.py
```
### 2. 使用默认配置文件
如果不指定配置文件,系统会按以下顺序查找:
1. 环境变量 `UNILABOS_BASICCONFIG_CONFIG_PATH` 指定的路径
2. 工作目录下的 `local_config.py`
3. 首次使用时会引导创建配置文件
### 3. 配置文件验证
系统启动时会自动验证配置文件:
- **语法检查**:确保 Python 语法正确
- **类型检查**:验证配置项类型是否匹配
- **必需项检查**:确保 `ak``sk` 已配置
## 最佳实践
### 1. 安全配置
- 不要将包含密钥的配置文件提交到版本控制系统
- 使用环境变量或命令行参数在生产环境中配置敏感信息
- 定期更换访问密钥
- **推荐配置方式**
```bash
# 生产环境 - 使用环境变量
export UNILABOS_BASICCONFIG_AK="your_access_key"
export UNILABOS_BASICCONFIG_SK="your_secret_key"
unilab
# 或使用命令行参数
unilab --ak "your_access_key" --sk "your_secret_key"
```
### 2. 多环境配置
为不同环境创建不同的配置文件并结合命令行参数:
```
configs/
├── local_config.py # 本地开发
├── test_config.py # 测试环境
├── prod_config.py # 生产环境
└── example_config.py # 示例配置
```
**环境切换示例**
```bash
# 本地开发环境
unilab --config configs/local_config.py --addr local
# 测试环境
unilab --config configs/test_config.py --addr test --upload_registry
# 生产环境
unilab --config configs/prod_config.py --ak "$PROD_AK" --sk "$PROD_SK"
```
### 3. 配置管理
- 保持配置文件简洁,只包含需要修改的配置项
- 为配置项添加注释说明其作用
- 定期检查和更新配置文件
- **命令行参数优先使用场景**
- 临时测试不同配置
- CI/CD 流水线中的动态配置
- 不同环境间快速切换
- 敏感信息的安全传递
### 4. 灵活配置策略
**基础配置文件 + 命令行覆盖**的推荐方式:
```python
# base_config.py - 基础配置
class BasicConfig:
# 非敏感配置写在文件中
is_host_mode = True
upload_registry = False
vis_2d_enable = False
class WSConfig:
reconnect_interval = 5
max_reconnect_attempts = 999
ping_interval = 30
```
```bash
# 启动时通过命令行覆盖关键参数
unilab --config base_config.py \
--ak "$AK" \
--sk "$SK" \
--addr "test" \
--upload_registry \
--2d_vis
```
## 故障排除
### 1. 配置文件加载失败
**错误信息**`[ENV] 配置文件 xxx 不存在`
**解决方法**
- 确认配置文件路径正确
- 检查文件权限是否可读
- 确保配置文件是 `.py` 格式
### 2. 语法错误
**错误信息**`[ENV] 加载配置文件 xxx 失败`
**解决方法**
- 检查 Python 语法是否正确
- 确认类名和字段名拼写正确
- 验证缩进是否正确(使用空格而非制表符)
### 3. 认证失败
**错误信息**`后续运行必须拥有一个实验室`
**解决方法**
- 确认 `ak` 和 `sk` 已正确配置
- 检查密钥是否有效
- 确认网络连接正常
### 4. 环境变量不生效
**解决方法**
- 确认环境变量名格式正确(`UNILABOS_CLASS_FIELD`
- 检查环境变量是否已正确设置
- 重启系统或重新加载环境变量
### 5. 命令行参数不生效
**错误现象**:设置了命令行参数但配置没有生效
**解决方法**
- 确认参数名拼写正确(如 `--ak` 而不是 `--access_key`
- 检查参数格式是否正确(布尔参数如 `--is_slave` 不需要值)
- 确认参数位置正确(所有参数都应在 `unilab` 之后)
- 查看启动日志确认参数是否被正确解析
### 6. 配置优先级混淆
**错误现象**:不确定哪个配置生效
**解决方法**
- 记住优先级:命令行参数 > 环境变量 > 配置文件
- 使用 `--ak` 和 `--sk` 参数时会看到提示信息
- 检查启动日志中的配置加载信息
- 临时移除低优先级配置来测试高优先级配置是否生效

View File

@@ -0,0 +1,860 @@
# 设备图文件说明
设备图文件定义了实验室中所有设备、资源及其连接关系。本文档说明如何创建和使用设备图文件。
## 概述
设备图文件采用 JSON 格式,节点定义基于 **`ResourceDict`** 标准模型(定义在 `unilabos.ros.nodes.resource_tracker`)。系统会自动处理旧格式并转换为标准格式,确保向后兼容性。
**核心概念**:
- **Nodes节点**: 代表设备或资源,通过 `parent` 字段建立层级关系
- **Links连接**: 可选的连接关系定义,用于展示设备间的物理或通信连接
- **UUID**: 全局唯一标识符,用于跨系统的资源追踪
- **自动转换**: 旧格式会通过 `ResourceDictInstance.get_resource_instance_from_dict()` 自动转换
## 文件格式
Uni-Lab 支持两种格式的设备图文件:
### JSON 格式(推荐)
**优点**:
- 易于编辑和阅读
- 支持注释(使用预处理)
- 与 Web 界面完全兼容
- 便于版本控制
**示例**: `workshop1.json`
### GraphML 格式
**优点**:
- 可用图形化工具编辑(如 yEd
- 适合复杂拓扑可视化
**示例**: `setup.graphml`
## JSON 文件结构
一个完整的 JSON 设备图文件包含两个主要部分:
```json
{
"nodes": [
/* 设备和资源节点 */
],
"links": [
/* 连接关系(可选)*/
]
}
```
### Nodes节点
每个节点代表一个设备或资源。节点的定义遵循 `ResourceDict` 标准模型:
```json
{
"id": "liquid_handler_1",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"name": "液体处理工作站",
"type": "device",
"class": "liquid_handler",
"config": {
"port": "/dev/ttyUSB0",
"baudrate": 9600
},
"data": {},
"position": {
"x": 100,
"y": 200
},
"parent": null
}
```
**字段说明(基于 ResourceDict 标准定义)**:
| 字段 | 必需 | 说明 | 示例 | 默认值 |
| ------------- | ---- | ------------------------ | ---------------------------------------------------- | -------- |
| `id` | ✓ | 唯一标识符 | `"pump_1"` | - |
| `uuid` | | 全局唯一标识符 (UUID) | `"550e8400-e29b-41d4-a716-446655440000"` | 自动生成 |
| `name` | ✓ | 显示名称 | `"主反应泵"` | - |
| `type` | ✓ | 节点类型 | `"device"`, `"resource"`, `"container"`, `"deck"` 等 | - |
| `class` | ✓ | 设备/资源类别 | `"liquid_handler"`, `"syringepump.runze"` | `""` |
| `config` | | Python 类的初始化参数 | `{"port": "COM3"}` | `{}` |
| `data` | | 资源的运行状态数据 | `{"status": "Idle", "position": 0.0}` | `{}` |
| `position` | | 在图中的位置 | `{"x": 100, "y": 200}` 或完整的 pose 结构 | - |
| `pose` | | 完整的 3D 位置信息 | 参见下文 | - |
| `parent` | | 父节点 ID | `"deck_1"` | `null` |
| `parent_uuid` | | 父节点 UUID | `"550e8400-..."` | `null` |
| `children` | | 子节点 ID 列表(旧格式) | `["child1", "child2"]` | - |
| `description` | | 资源描述 | `"用于精确控制试剂A的加料速率"` | `""` |
| `schema` | | 资源 schema 定义 | `{}` | `{}` |
| `model` | | 资源 3D 模型信息 | `{}` | `{}` |
| `icon` | | 资源图标 | `"pump.webp"` | `""` |
| `extra` | | 额外的自定义数据 | `{"custom_field": "value"}` | `{}` |
### Position 和 Pose位置信息
**简单格式(旧格式,兼容)**:
```json
"position": {
"x": 100,
"y": 200,
"z": 0
}
```
**完整格式(推荐)**:
```json
"pose": {
"size": {
"width": 127.76,
"height": 85.48,
"depth": 10.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
},
"layout": "x-y",
"position": {
"x": 100,
"y": 200,
"z": 0
},
"position3d": {
"x": 100,
"y": 200,
"z": 0
},
"rotation": {
"x": 0,
"y": 0,
"z": 0
},
"cross_section_type": "rectangle"
}
```
### Links连接
定义节点之间的连接关系(可选,主要用于物理连接或通信关系的可视化):
```json
{
"source": "pump_1",
"target": "reactor_1",
"sourceHandle": "output",
"targetHandle": "input",
"type": "physical"
}
```
**字段说明**:
| 字段 | 必需 | 说明 | 示例 |
| -------------- | ---- | ---------------- | ---------------------------------------- |
| `source` | ✓ | 源节点 ID | `"pump_1"` |
| `target` | ✓ | 目标节点 ID | `"reactor_1"` |
| `sourceHandle` | | 源节点的连接点 | `"output"` |
| `targetHandle` | | 目标节点的连接点 | `"input"` |
| `type` | | 连接类型 | `"physical"`, `"communication"` |
| `port` | | 端口映射信息 | `{"source": "port1", "target": "port2"}` |
**注意**: Links 主要用于图形化展示和文档说明,父子关系通过 `parent` 字段定义,不依赖 links。
## 完整示例
### 示例 1液体处理工作站PRCXI9300
这是一个真实的液体处理工作站配置,包含设备、工作台和多个板资源。
**文件位置**: `test/experiments/prcxi_9300.json`
```json
{
"nodes": [
{
"id": "PRCXI9300",
"name": "PRCXI9300",
"parent": null,
"type": "device",
"class": "liquid_handler.prcxi",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"deck": {
"_resource_child_name": "PRCXI_Deck_9300",
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck"
},
"host": "10.181.214.132",
"port": 9999,
"timeout": 10.0,
"axis": "Left",
"channel_num": 8,
"setup": false,
"debug": true,
"simulator": true,
"matrix_id": "71593"
},
"data": {},
"children": ["PRCXI_Deck_9300"]
},
{
"id": "PRCXI_Deck_9300",
"name": "PRCXI_Deck_9300",
"parent": "PRCXI9300",
"type": "deck",
"class": "",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "PRCXI9300Deck",
"size_x": 100,
"size_y": 100,
"size_z": 100,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "deck"
},
"data": {},
"children": [
"RackT1",
"PlateT2",
"trash",
"PlateT4",
"PlateT5",
"PlateT6"
]
},
{
"id": "RackT1",
"name": "RackT1",
"parent": "PRCXI_Deck_9300",
"type": "tip_rack",
"class": "",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "TipRack",
"size_x": 127.76,
"size_y": 85.48,
"size_z": 100
},
"data": {},
"children": []
}
]
}
```
**关键点**:
- 使用 `parent` 字段建立层级关系PRCXI9300 → Deck → Rack/Plate
- 使用 `children` 字段(旧格式)列出子节点
- `config` 中包含设备特定的连接参数
- `data` 存储运行时状态
- `position` 使用简单的 x/y/z 坐标
### 示例 2有机合成工作站带 Links
这是一个格林纳德反应的流动化学工作站配置,展示了完整的设备连接和通信关系。
**文件位置**: `test/experiments/Grignard_flow_batchreact_single_pumpvalve.json`
```json
{
"nodes": [
{
"id": "YugongStation",
"name": "愚公常量合成工作站",
"parent": null,
"type": "device",
"class": "workstation",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"protocol_type": [
"PumpTransferProtocol",
"CleanProtocol",
"SeparateProtocol",
"EvaporateProtocol"
]
},
"data": {},
"children": [
"serial_pump",
"pump_reagents",
"flask_CH2Cl2",
"reactor",
"pump_workup",
"separator_controller",
"flask_separator",
"rotavap",
"column"
]
},
{
"id": "serial_pump",
"name": "serial_pump",
"parent": "YugongStation",
"type": "device",
"class": "serial",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"port": "COM7",
"baudrate": 9600
},
"data": {},
"children": []
},
{
"id": "pump_reagents",
"name": "pump_reagents",
"parent": "YugongStation",
"type": "device",
"class": "syringepump.runze",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"port": "/devices/PumpBackbone/Serial/serialwrite",
"address": "1",
"max_volume": 25.0
},
"data": {
"max_velocity": 1.0,
"position": 0.0,
"status": "Idle",
"valve_position": "0"
},
"children": []
},
{
"id": "reactor",
"name": "reactor",
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 430.4087301587302,
"y": 428,
"z": 0
},
"config": {},
"data": {},
"children": []
}
],
"links": [
{
"source": "pump_reagents",
"target": "serial_pump",
"type": "communication",
"port": {
"pump_reagents": "port",
"serial_pump": "port"
}
},
{
"source": "pump_workup",
"target": "serial_pump",
"type": "communication",
"port": {
"pump_workup": "port",
"serial_pump": "port"
}
}
]
}
```
**关键点**:
- 多级设备层次:工作站包含多个子设备和容器
- `links` 定义通信关系(泵通过串口连接)
- `data` 字段存储设备状态(如泵的位置、速度等)
- `class` 可以使用点号分层(如 `"syringepump.runze"`
- 容器的 `class` 可以为 `null`
## 格式兼容性和转换
### 旧格式自动转换
Uni-Lab 使用 `ResourceDictInstance.get_resource_instance_from_dict()` 方法自动处理旧格式的节点数据,确保向后兼容性。
**自动转换规则**:
1. **自动生成缺失字段**:
```python
# 如果缺少 id使用 name 作为 id
if "id" not in content:
content["id"] = content["name"]
# 如果缺少 uuid自动生成
if "uuid" not in content:
content["uuid"] = str(uuid.uuid4())
```
2. **Position 格式转换**:
```python
# 旧格式:简单的 x/y 坐标
"position": {"x": 100, "y": 200}
# 自动转换为新格式
"position": {
"position": {"x": 100, "y": 200}
}
```
3. **默认值填充**:
```python
# 自动填充空字段
if not content.get("class"):
content["class"] = ""
if not content.get("config"):
content["config"] = {}
if not content.get("data"):
content["data"] = {}
if not content.get("extra"):
content["extra"] = {}
```
4. **Pose 字段同步**:
```python
# 如果没有 pose使用 position
if "pose" not in content:
content["pose"] = content.get("position", {})
```
### 使用示例
```python
from unilabos.ros.nodes.resource_tracker import ResourceDictInstance
# 旧格式节点
old_format_node = {
"name": "pump_1",
"type": "device",
"class": "syringepump",
"position": {"x": 100, "y": 200}
}
# 自动转换为标准格式
instance = ResourceDictInstance.get_resource_instance_from_dict(old_format_node)
# 访问标准化后的数据
print(instance.res_content.id) # "pump_1"
print(instance.res_content.uuid) # 自动生成的 UUID
print(instance.res_content.config) # {}
print(instance.res_content.data) # {}
```
### 格式迁移建议
虽然系统会自动处理旧格式,但建议在新文件中使用完整的标准格式:
| 字段 | 旧格式(兼容) | 新格式(推荐) |
| ------ | ---------------------------------- | ------------------------------------------------ |
| 标识符 | 仅 `id` 或仅 `name` | `id` + `uuid` |
| 位置 | `"position": {"x": 100, "y": 200}` | 完整的 `pose` 结构 |
| 父节点 | `"parent": "parent_id"` | `"parent": "parent_id"` + `"parent_uuid": "..."` |
| 配置 | 可省略 | 显式设置为 `{}` |
| 数据 | 可省略 | 显式设置为 `{}` |
## 节点类型详解
### Device 节点
设备节点代表实际的硬件设备:
```json
{
"id": "device_id",
"name": "设备名称",
"type": "device",
"class": "设备类别",
"parent": null,
"config": {
"port": "COM3"
},
"data": {},
"children": []
}
```
**常见设备类别**:
- `liquid_handler`: 液体处理工作站
- `liquid_handler.prcxi`: PRCXI 液体处理工作站
- `syringepump`: 注射泵
- `syringepump.runze`: 润泽注射泵
- `heaterstirrer`: 加热搅拌器
- `balance`: 天平
- `reactor_vessel`: 反应釜
- `serial`: 串口通信设备
- `workstation`: 自动化工作站
### Resource 节点
资源节点代表物料容器、载具等:
```json
{
"id": "resource_id",
"name": "资源名称",
"type": "resource",
"class": "资源类别",
"parent": "父节点ID",
"config": {
"size_x": 127.76,
"size_y": 85.48,
"size_z": 100
},
"data": {},
"children": []
}
```
**常见资源类型**:
- `deck`: 工作台/甲板
- `plate`: 板96 孔板等)
- `tip_rack`: 枪头架
- `tube`: 试管
- `container`: 容器
- `well`: 孔位
- `bottle_carrier`: 瓶架
## Handle连接点
每个设备和资源可以有多个连接点handles用于定义可以连接的接口。
### 查看可用 handles
设备和资源的可用 handles 定义在注册表中:
```yaml
# 设备注册表示例
liquid_handler:
handles:
- handler_key: pipette
io_type: source
- handler_key: deck
io_type: target
```
### 常见 handles
| 设备类型 | Source Handles | Target Handles |
| ---------- | -------------- | -------------- |
| 泵 | output | input |
| 反应釜 | output, vessel | input |
| 液体处理器 | pipette | deck |
| 板 | wells | access |
## 使用 Web 界面创建图文件
Uni-Lab 提供 Web 界面来可视化创建和编辑设备图:
### 1. 启动 Uni-Lab
```bash
unilab
```
### 2. 访问 Web 界面
打开浏览器访问 `http://localhost:8002`
### 3. 图形化编辑
- 拖拽添加设备和资源
- 连线建立连接关系
- 编辑节点属性
- 保存为 JSON 文件
### 4. 导出图文件
点击"导出"按钮,下载 JSON 文件到本地。
## 从云端获取图文件
如果不指定`-g`参数Uni-Lab 会自动从云端获取:
```bash
# 使用云端配置
unilab
# 日志会显示:
# [INFO] 未指定设备加载文件路径尝试从HTTP获取...
# [INFO] 联网获取设备加载文件成功
```
**云端图文件管理**:
1. 登录 https://uni-lab.bohrium.com
2. 进入"设备配置"
3. 创建或编辑配置
4. 保存到云端
本地启动时会自动同步最新配置。
## 调试图文件
### 验证 JSON 格式
```bash
# 使用Python验证
python -c "import json; json.load(open('workshop1.json'))"
# 使用在线工具
# https://jsonlint.com/
```
### 检查节点引用
确保:
- 所有`links`中的`source`和`target`都存在于`nodes`中
- `parent`字段指向的节点存在
- `class`字段对应的设备/资源在注册表中存在
### 启动时验证
```bash
# Uni-Lab启动时会验证图文件
unilab -g workshop1.json
# 查看日志中的错误或警告
# [ERROR] 节点 xxx 的source端点 yyy 不存在
# [WARNING] 节点 zzz missing 'name', defaulting to ...
```
## 最佳实践
### 1. 命名规范
```json
{
"id": "pump_reagent_1", // 小写+下划线,描述性
"name": "试剂进料泵A", // 中文显示名称
"class": "syringepump" // 使用注册表中的精确名称
}
```
### 2. 层级组织
```
host_node (主节点)
└── liquid_handler_1 (设备)
└── deck_1 (资源)
├── tiprack_1 (资源)
├── plate_1 (资源)
└── reservoir_1 (资源)
```
### 3. 配置分离
将设备特定配置放在`config`中:
```json
{
"id": "pump_1",
"class": "syringepump",
"config": {
"port": "COM3", // 设备特定
"max_flow_rate": 10, // 设备特定
"volume": 50 // 设备特定
}
}
```
### 4. 版本控制
```bash
# 使用Git管理图文件
git add workshop1.json
git commit -m "Add new liquid handler configuration"
# 使用有意义的文件名
workshop_v1.json
workshop_production.json
workshop_test.json
```
### 5. 注释(通过描述字段)
虽然 JSON 不支持注释,但可以使用`description`字段:
```json
{
"id": "pump_1",
"name": "进料泵",
"description": "用于精确控制试剂A的加料速率最大流速10mL/min",
"class": "syringepump"
}
```
## 示例文件位置
Uni-Lab 在安装时已预置了 **40+ 个真实的设备图文件示例**,位于 `unilabos/test/experiments/` 目录。这些都是真实项目中使用的配置文件,可以直接使用或作为参考。
### 📁 主要示例文件
```
test/experiments/
├── workshop.json # 综合工作台(推荐新手)
├── empty_devices.json # 空设备配置(最小化)
├── prcxi_9300.json # PRCXI液体处理工作站本文示例1
├── prcxi_9320.json # PRCXI 9320工作站
├── biomek.json # Biomek液体处理工作站
├── Grignard_flow_batchreact_single_pumpvalve.json # 格林纳德反应工作站本文示例2
├── dispensing_station_bioyond.json # Bioyond配液站
├── reaction_station_bioyond.json # Bioyond反应站
├── HPLC.json # HPLC分析系统
├── plr_test.json # PyLabRobot测试配置
├── lidocaine-graph.json # 利多卡因合成工作站
├── opcua_example.json # OPC UA设备集成示例
├── mock_devices/ # 虚拟设备(用于离线测试)
│ ├── mock_all.json # 完整虚拟设备集
│ ├── mock_pump.json # 虚拟泵
│ ├── mock_stirrer.json # 虚拟搅拌器
│ ├── mock_heater.json # 虚拟加热器
│ └── ... # 更多虚拟设备
├── Protocol_Test_Station/ # 协议测试工作站
│ ├── pumptransfer_test_station.json # 泵转移协议测试
│ ├── heatchill_protocol_test_station.json # 加热冷却协议测试
│ ├── filter_protocol_test_station.json # 过滤协议测试
│ └── ... # 更多协议测试
└── comprehensive_protocol/ # 综合协议示例
├── comprehensive_station.json # 综合工作站
└── comprehensive_slim.json # 精简版综合工作站
```
### 🚀 快速使用
无需下载或创建,直接使用 `-g` 参数指定路径:
```bash
# 使用简单工作台(推荐新手)
unilab --ak your_ak --sk your_sk -g test/experiments/workshop.json
# 使用虚拟设备(无需真实硬件)
unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
# 使用 PRCXI 液体处理工作站
unilab --ak your_ak --sk your_sk -g test/experiments/prcxi_9300.json
# 使用格林纳德反应工作站
unilab --ak your_ak --sk your_sk -g test/experiments/Grignard_flow_batchreact_single_pumpvalve.json
```
### 📚 文件分类
| 类别 | 说明 | 文件数量 |
| ------------ | ------------------------ | -------- |
| **主工作站** | 完整的实验工作站配置 | 15+ |
| **虚拟设备** | 用于开发测试的 mock 设备 | 10+ |
| **协议测试** | 各种实验协议的测试配置 | 12+ |
| **综合示例** | 包含多种协议的综合工作站 | 3+ |
这些文件展示了不同场景下的设备图配置,涵盖液体处理、有机合成、分析检测等多个领域,是学习和创建自己配置的绝佳参考。
## 快速参考ResourceDict 完整字段列表
基于 `unilabos.ros.nodes.resource_tracker.ResourceDict` 的完整字段定义:
```python
class ResourceDict(BaseModel):
# === 基础标识 ===
id: str # 资源ID必需
uuid: str # 全局唯一标识符(自动生成)
name: str # 显示名称(必需)
# === 类型和分类 ===
type: Union[Literal["device"], str] # 节点类型(必需)
klass: str # 资源类别alias="class",必需)
# === 层级关系 ===
parent: Optional[ResourceDict] # 父资源对象(不序列化)
parent_uuid: Optional[str] # 父资源UUID
# === 位置和姿态 ===
position: ResourceDictPosition # 位置信息
pose: ResourceDictPosition # 姿态信息(推荐使用)
# === 配置和数据 ===
config: Dict[str, Any] # 设备配置参数
data: Dict[str, Any] # 运行时状态数据
extra: Dict[str, Any] # 额外自定义数据
# === 元数据 ===
description: str # 资源描述
resource_schema: Dict[str, Any] # schema定义alias="schema"
model: Dict[str, Any] # 3D模型信息
icon: str # 图标路径
```
**Position/Pose 结构**:
```python
class ResourceDictPosition(BaseModel):
size: ResourceDictPositionSize # width, height, depth
scale: ResourceDictPositionScale # x, y, z
layout: Literal["2d", "x-y", "z-y", "x-z"]
position: ResourceDictPositionObject # x, y, z
position3d: ResourceDictPositionObject # x, y, z
rotation: ResourceDictPositionObject # x, y, z
cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"]
```
## 下一步
- {doc}`../boot_examples/index` - 查看完整启动示例
- {doc}`../developer_guide/add_device` - 了解如何添加新设备
- {doc}`06_troubleshooting` - 图文件相关问题排查
- 源码参考: `unilabos/ros/nodes/resource_tracker.py` - ResourceDict 标准定义
## 获取帮助
- 在 Web 界面中使用模板创建
- 参考示例文件:`test/experiments/` 目录
- 查看 ResourceDict 源码了解完整定义
- [GitHub 讨论区](https://github.com/dptech-corp/Uni-Lab-OS/discussions)

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -1,43 +1,516 @@
# **Uni-Lab 安装**
# Uni-Lab-OS 安装指南
## 快速开始
本指南提供 Uni-Lab-OS 的完整安装说明,涵盖从快速一键安装到完整开发环境配置的所有方式。
1. **配置 Conda 环境**
## 系统要求
Uni-Lab-OS 建议使用 `mamba` 管理环境。创建新的环境:
- **操作系统**: Windows 10/11, Linux (Ubuntu 20.04+), macOS (10.15+)
- **内存**: 最小 4GB推荐 8GB 以上
- **磁盘空间**: 至少 10GB 可用空间
- **网络**: 稳定的互联网连接(用于下载软件包)
- **其他**:
- 已安装 Conda/Miniconda/Miniforge/Mamba
- 开发者需要 Git 和基本的 Python 开发知识
- 自定义 msgs 需要 GitHub 账号
```shell
## 安装方式选择
根据您的使用场景,选择合适的安装方式:
| 安装方式 | 适用人群 | 特点 | 安装时间 |
| ---------------------- | -------------------- | ------------------------------ | ---------------------------- |
| **方式一:一键安装** | 实验室用户、快速体验 | 预打包环境,离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) |
| **方式二:手动安装** | 标准用户、生产环境 | 灵活配置,版本可控 | 10-20 分钟 |
| **方式三:开发者安装** | 开发者、需要修改源码 | 可编辑模式,支持自定义 msgs | 20-30 分钟 |
---
## 方式一:一键安装(推荐新用户)
使用预打包的 conda 环境,最快速的安装方法。
### 前置条件
确保已安装 Conda/Miniconda/Miniforge/Mamba。
### 安装步骤
#### 第一步:下载预打包环境
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
2. 选择最新的成功构建记录(绿色勾号 ✓)
3. 在页面底部的 "Artifacts" 部分,下载对应你操作系统的压缩包:
- Windows: `unilab-pack-win-64-{branch}.zip`
- macOS (Intel): `unilab-pack-osx-64-{branch}.tar.gz`
- macOS (Apple Silicon): `unilab-pack-osx-arm64-{branch}.tar.gz`
- Linux: `unilab-pack-linux-64-{branch}.tar.gz`
#### 第二步:解压并运行安装脚本
**Windows**:
```batch
REM 使用 Windows 资源管理器解压下载的 zip 文件
REM 或使用命令行:
tar -xzf unilab-pack-win-64-dev.zip
REM 进入解压后的目录
cd unilab-pack-win-64-dev
REM 双击运行 install_unilab.bat
REM 或在命令行中执行:
install_unilab.bat
```
**macOS**:
```bash
# 解压下载的压缩包
tar -xzf unilab-pack-osx-arm64-dev.tar.gz
# 进入解压后的目录
cd unilab-pack-osx-arm64-dev
# 运行安装脚本
bash install_unilab.sh
```
**Linux**:
```bash
# 解压下载的压缩包
tar -xzf unilab-pack-linux-64-dev.tar.gz
# 进入解压后的目录
cd unilab-pack-linux-64-dev
# 添加执行权限(如果需要)
chmod +x install_unilab.sh
# 运行安装脚本
./install_unilab.sh
```
#### 第三步:激活环境
```bash
conda activate unilab
```
激活后,您的命令行提示符应该会显示 `(unilab)` 前缀。
---
## 方式二:手动安装(标准用户)
适合生产环境和需要灵活配置的用户。
### 第一步:安装 Mamba 环境管理器
Mamba 是 Conda 的快速替代品,我们强烈推荐使用 Mamba 来管理 Uni-Lab 环境。
#### Windows
下载并安装 Miniforge包含 Mamba:
```powershell
# 访问 https://github.com/conda-forge/miniforge/releases
# 下载 Miniforge3-Windows-x86_64.exe
# 运行安装程序
# 也可以使用镜像站 https://mirrors.tuna.tsinghua.edu.cn/github-release/conda-forge/miniforge/LatestRelease/
# 下载 Miniforge3-Windows-x86_64.exe
# 运行安装程序
```
#### Linux/macOS
```bash
# 下载 Miniforge 安装脚本
curl -L -O "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"
# 运行安装
bash Miniforge3-$(uname)-$(uname -m).sh
# 按照提示完成安装,建议选择 yes 来初始化
```
安装完成后,重新打开终端使 Mamba 生效。
### 第二步:创建 Uni-Lab 环境
使用以下命令创建 Uni-Lab 专用环境:
```bash
mamba create -n unilab python=3.11.11 # 目前ros2组件依赖版本大多为3.11.11
mamba activate unilab
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
```
**参数说明**:
- `-n unilab`: 创建名为 "unilab" 的环境
- `uni-lab::unilabos`: 从 uni-lab channel 安装 unilabos 包
- `-c robostack-staging -c conda-forge`: 添加额外的软件源
**如果遇到网络问题**,可以使用清华镜像源加速下载:
```bash
# 配置清华镜像源
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
# 然后重新执行安装命令
mamba create -n unilab uni-lab::unilabos -c robostack-staging
```
### 第三步:激活环境
```bash
conda activate unilab
```
---
## 方式三:开发者安装
适用于需要修改 Uni-Lab 源代码或开发新设备驱动的开发者。
### 前置条件
- 已安装 Git
- 已安装 Mamba/Conda
- 有 GitHub 账号(如需自定义 msgs
- 基本的 Python 开发知识
### 第一步:克隆仓库
```bash
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
cd Uni-Lab-OS
```
如果您需要贡献代码,建议先 Fork 仓库:
1. 访问 https://github.com/dptech-corp/Uni-Lab-OS
2. 点击右上角的 "Fork" 按钮
3. Clone 您的 Fork 版本:
```bash
git clone https://github.com/YOUR_USERNAME/Uni-Lab-OS.git
cd Uni-Lab-OS
```
### 第二步:安装基础环境
**推荐方式**:先通过**方式一(一键安装)**或**方式二(手动安装)**完成基础环境的安装这将包含所有必需的依赖项ROS2、msgs 等)。
#### 选项 A通过一键安装推荐
参考上文"方式一:一键安装",完成基础环境的安装后,激活环境:
```bash
conda activate unilab
```
#### 选项 B通过手动安装
参考上文"方式二:手动安装",创建并安装环境:
```bash
mamba create -n unilab python=3.11.11
conda activate unilab
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
```
**说明**:这会安装包括 Python 3.11.11、ROS2 Humble、ros-humble-unilabos-msgs 和所有必需依赖
### 第三步:切换到开发版本
现在你已经有了一个完整可用的 Uni-Lab 环境,接下来将 unilabos 包切换为开发版本:
```bash
# 确保环境已激活
conda activate unilab
# 卸载 pip 安装的 unilabos保留所有 conda 依赖)
pip uninstall unilabos -y
# 克隆 dev 分支(如果还未克隆)
cd /path/to/your/workspace
git clone -b dev https://github.com/dptech-corp/Uni-Lab-OS.git
# 或者如果已经克隆,切换到 dev 分支
cd Uni-Lab-OS
git checkout dev
git pull
# 以可编辑模式安装开发版 unilabos
pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
```
**参数说明**
- `-e`: editable mode可编辑模式代码修改立即生效无需重新安装
- `-i`: 使用清华镜像源加速下载
- `pip uninstall unilabos`: 只卸载 pip 安装的 unilabos 包,不影响 conda 安装的其他依赖(如 ROS2、msgs 等)
### 第四步:安装或自定义 ros-humble-unilabos-msgs可选
Uni-Lab 使用 ROS2 消息系统进行设备间通信。如果你使用方式一或方式二安装msgs 包已经自动安装。
#### 使用已安装的 msgs大多数用户
如果你不需要修改 msgs可以跳过此步骤直接使用已安装的 msgs 包。验证安装:
```bash
# 列出所有 unilabos_msgs 接口
ros2 interface list | grep unilabos_msgs
# 查看特定 action 定义
ros2 interface show unilabos_msgs/action/DeviceCmd
```
#### 自定义 msgs高级用户
如果你需要:
- 添加新的 ROS2 action 定义
- 修改现有 msg/srv/action 接口
- 为特定设备定制通信协议
请参考 **[添加新动作指令Action指南](../developer_guide/add_action.md)**,该指南详细介绍了如何:
- 编写新的 Action 定义
- 在线构建 Action通过 GitHub Actions
- 下载并安装自定义的 msgs 包
- 测试和验证新的 Action
```bash
# 安装自定义构建的 msgs 包
mamba remove --force ros-humble-unilabos-msgs
mamba config set safety_checks disabled # 关闭 md5 检查
mamba install /path/to/ros-humble-unilabos-msgs-*.conda --offline
```
### 第五步:验证开发环境
完成上述步骤后,验证开发环境是否正确配置:
```bash
# 确保环境已激活
conda activate unilab
# 检查 ROS2 环境
ros2 --version
# 检查 msgs 包
ros2 interface list | grep unilabos_msgs
# 检查 Python 可以导入 unilabos
python -c "import unilabos; print(f'Uni-Lab版本: {unilabos.__version__}')"
# 检查 unilab 命令
unilab --help
```
如果所有命令都正常输出,说明开发环境配置成功!
---
## 验证安装
无论使用哪种安装方式,都应该验证安装是否成功。
### 基本验证
```bash
# 确保已激活环境
conda activate unilab # 或 unilab-dev
# 检查 unilab 命令
unilab --help
```
您应该看到类似以下的输出:
```
usage: unilab [-h] [-g GRAPH] [-c CONTROLLERS] [--registry_path REGISTRY_PATH]
[--working_dir WORKING_DIR] [--backend {ros,simple,automancer}]
...
```
### 检查版本
```bash
python -c "import unilabos; print(f'Uni-Lab版本: {unilabos.__version__}')"
```
### 使用验证脚本(方式一)
如果使用一键安装,可以运行预打包的验证脚本:
```bash
# 确保已激活环境
conda activate unilab
# 运行验证脚本
python verify_installation.py
```
如果看到 "✓ All checks passed!",说明安装成功!
---
## 常见问题
### 问题 1: 找不到 unilab 命令
**原因**: 环境未正确激活或 PATH 未设置
**解决方案**:
```bash
# 确保激活了正确的环境
conda activate unilab
# 检查 unilab 是否在 PATH 中
which unilab # Linux/macOS
where unilab # Windows
```
### 问题 2: 包冲突或依赖错误
**解决方案**:
```bash
# 删除旧环境重新创建
conda deactivate
conda env remove -n unilab
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
```
2. **安装开发版 Uni-Lab-OS**
### 问题 3: 下载速度慢
```shell
# 配置好conda环境后克隆仓库
git clone https://github.com/dptech-corp/Uni-Lab-OS.git -b dev
cd Uni-Lab-OS
**解决方案**: 使用国内镜像源(清华、中科大等)
# 安装 Uni-Lab-OS
pip install -e .
```bash
# 查看当前 channel 配置
conda config --show channels
# 添加清华镜像
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
```
3. **安装开发版 ros-humble-unilabos-msgs**
### 问题 4: 权限错误
**卸载老版本:**
```shell
**Windows 解决方案**: 以管理员身份运行命令提示符
**Linux/macOS 解决方案**:
```bash
# 不要使用 sudo 安装 conda 包
# 如果 conda 安装在需要权限的位置,考虑重新安装 conda 到用户目录
```
### 问题 5: 安装脚本找不到 conda方式一
**解决方案**: 确保你已经安装了 conda/miniconda/miniforge并且安装在标准位置
- **Windows**:
- `%USERPROFILE%\miniforge3`
- `%USERPROFILE%\miniconda3`
- `%USERPROFILE%\anaconda3`
- `C:\ProgramData\miniforge3`
- **macOS/Linux**:
- `~/miniforge3`
- `~/miniconda3`
- `~/anaconda3`
- `/opt/conda`
如果安装在其他位置,可以先激活 conda base 环境,然后手动运行安装脚本。
### 问题 6: 安装后激活环境提示找不到?
**解决方案**: 尝试以下方法:
```bash
# 方法 1: 使用 conda activate
conda activate unilab
conda remove --force ros-humble-unilabos-msgs
```
有时相同的安装包版本会由于dev构建得到的md5不一样触发安全检查可输入 `config set safety_checks disabled` 来关闭安全检查。
**安装新版本:**
# 方法 2: 使用完整路径激活Windows
call C:\Users\{YourUsername}\miniforge3\envs\unilab\Scripts\activate.bat
访问 https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/multi-platform-build.yml 选择最新的构建,下载对应平台的压缩包(仅解压一次,得到.conda文件使用如下指令
```shell
conda activate base
conda install ros-humble-unilabos-msgs-<version>-<platform>.conda --offline -n <环境名>
# 方法 2: 使用完整路径激活Unix
source ~/miniforge3/envs/unilab/bin/activate
```
4. **启动 Uni-Lab 系统**
### 问题 7: conda-unpack 失败怎么办?(方式一)
请参见{doc}`启动样例 <../boot_examples/index>`或{doc}`启动指南 <launch>`了解详细的启动方法。
**解决方案**: 尝试手动运行:
```bash
# Windows
cd %CONDA_PREFIX%\envs\unilab
.\Scripts\conda-unpack.exe
# macOS/Linux
cd $CONDA_PREFIX/envs/unilab
./bin/conda-unpack
```
### 问题 8: 环境很大,有办法减小吗?
**解决方案**: 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB。这是为了确保离线安装和完整功能。如果空间有限考虑使用方式二手动安装只安装需要的组件。
### 问题 9: 如何更新到最新版本?
**解决方案**:
**方式一用户**: 重新下载最新的预打包环境,运行安装脚本时选择覆盖现有环境。
**方式二/三用户**: 在现有环境中更新:
```bash
conda activate unilab
# 更新 unilabos
cd /path/to/Uni-Lab-OS
git pull
pip install -e . --upgrade -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
# 更新 ros-humble-unilabos-msgs
mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge
```
---
## 下一步
安装完成后,请继续:
- **快速启动**: 学习如何首次启动 Uni-Lab
- **配置指南**: 配置您的实验室环境和设备
- **运行示例**: 查看启动示例和最佳实践
- **开发指南**:
- 添加新设备驱动
- 添加新物料资源
- 了解工作站架构
## 需要帮助?
- **故障排查**: 查看更详细的故障排查信息
- **GitHub Issues**: [报告问题](https://github.com/dptech-corp/Uni-Lab-OS/issues)
- **开发者文档**: 查看开发者指南获取更多技术细节
- **社区讨论**: [GitHub Discussions](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
---
**提示**:
- 生产环境推荐使用方式二(手动安装)的稳定版本
- 开发和测试推荐使用方式三(开发者安装)
- 快速体验和演示推荐使用方式一(一键安装)

View File

@@ -132,15 +132,14 @@ unilab --config path/to/your/config.py
使用 `-c` 传入控制逻辑配置。
不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry_path` 加入 `<your-registry-path>/{devices,device_comms,resources}`
不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry_path` 加入 `<your-registry-path>/{devices,device_comms,resources}`,只输入<your-registry-path>即可,支持多次--registry_path指定多个目录
## 通信中间件 `--backend`
目前 Uni-Lab 支持以下通信中间件:
- **ros** (默认):基于 ROS2 的通信
- **simple**:简化通信模式
- **automancer**Automancer 兼容模式
- **automancer**Automancer 兼容模式 (实验性)
## 端云桥接 `--app_bridges`
@@ -169,7 +168,7 @@ unilab --config path/to/your/config.py
通过 `--visual` 参数选择:
- **rviz**:使用 RViz 进行 3D 可视化
- **web**:使用 Web 界面进行可视化
- **web**:使用 Web 界面进行可视化 (基于Pylabrobot)
- **disable** (默认):禁用可视化
## 实验室管理
@@ -245,78 +244,3 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
- 检查图谱文件格式是否正确
- 验证设备连接和端点配置
- 确保注册表路径正确
## 页面操作
### 1. 启动成功
当您启动成功后,可以看到物料列表,节点模版和组态图如图展示
![material.png](image/material.png)
### 2. 根据需求创建设备和物料
我们可以做一个简单的案例
* 在容器1中加入水
* 通过传输泵将容器1中的水转移到容器2中
#### 2.1 添加所需的设备和物料
仪器设备work_station中的workstation 数量x1
仪器设备virtual_device中的virtual_transfer_pump 数量x1
物料耗材container中的container 数量x2
#### 2.2 将设备和物料根据父子关系进行关联
当我们添加设备时,仪器耗材模块的物料列表也会实时更新
我们需要将设备和物料拖拽到workstation中并在画布上将它们连接起来就像真实的设备操作一样
![links.png](image/links.png)
### 3. 创建工作流
进入工作流模块 → 点击"我创建的" → 新建工作流
![new.png](image/new.png)
#### 3.1 新增工作流节点
我们可以进入指定工作流,在空白处右键
* 选择Laboratory→host_node中的creat_resource
* 选择Laboratory→workstation中的PumpTransferProtocol
![creatworkfollow.gif](image/creatworkfollow.gif)
#### 3.2 配置节点参数
根据案例,工作流包含两个步骤:
1. 使用creat_resource在容器中创建水
2. 通过泵传输协议将水传输到另一个容器
我们点击creat_resource卡片上的编辑按钮来配置参数⭐
class_name container
device_id workstation
liquid_input_slot 0或-1均可
liquid_type : water
liquid_volume 根据需求填写即可默认单位ml这里举例50
parent workstation
res_id containe
关联设备名称(原unilabos_device_id) 这里就填写host_node
**配置完成后点击底部保存按钮**
我们点击PumpTransferProtocol卡片上的编辑按钮来配置参数⭐
event transfer_liquid
from_vessel water
to_vessel container1
volume 根据需求填写即可默认单位ml这里举例50
关联设备名称(原unilabos_device_id) 这里就填写workstation
**配置完成后点击底部保存按钮**
#### 3.3 运行工作流
1. 连接两个节点卡片
2. 点击底部保存按钮
3. 点击运行按钮执行工作流
![linksandrun.png](image/linksandrun.png)
### 运行监控
* 运行状态和消息实时显示在底部控制台
* 如有报错,可点击查看详细信息
### 结果验证
工作流完成后,返回仪器耗材模块:
* 点击 container1卡片查看详情
* 确认其中包含参数指定的水和容量

View File

@@ -1,197 +0,0 @@
# Uni-Lab-OS 一键安装快速指南
## 概述
本指南提供最快速的 Uni-Lab-OS 安装方法,使用预打包的 conda 环境,无需手动配置依赖。
## 前置要求
- 已安装 Conda/Miniconda/Miniforge/Mamba
- 至少 10GB 可用磁盘空间
- Windows 10+, macOS 10.14+, 或 Linux (Ubuntu 20.04+)
## 安装步骤
### 第一步:下载预打包环境
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
2. 选择最新的成功构建记录(绿色勾号 ✓)
3. 在页面底部的 "Artifacts" 部分,下载对应你操作系统的压缩包:
- Windows: `unilab-pack-win-64-{branch}.zip`
- macOS (Intel): `unilab-pack-osx-64-{branch}.tar.gz`
- macOS (Apple Silicon): `unilab-pack-osx-arm64-{branch}.tar.gz`
- Linux: `unilab-pack-linux-64-{branch}.tar.gz`
### 第二步:解压并运行安装脚本
#### Windows
```batch
REM 使用 Windows 资源管理器解压下载的 zip 文件
REM 或使用命令行:
tar -xzf unilab-pack-win-64-dev.zip
REM 进入解压后的目录
cd unilab-pack-win-64-dev
REM 双击运行 install_unilab.bat
REM 或在命令行中执行:
install_unilab.bat
```
#### macOS
```bash
# 解压下载的压缩包
tar -xzf unilab-pack-osx-arm64-dev.tar.gz
# 进入解压后的目录
cd unilab-pack-osx-arm64-dev
# 运行安装脚本
bash install_unilab.sh
```
#### Linux
```bash
# 解压下载的压缩包
tar -xzf unilab-pack-linux-64-dev.tar.gz
# 进入解压后的目录
cd unilab-pack-linux-64-dev
# 添加执行权限(如果需要)
chmod +x install_unilab.sh
# 运行安装脚本
./install_unilab.sh
```
### 第三步:激活环境
```bash
conda activate unilab
```
### 第四步:验证安装(推荐)
```bash
# 确保已激活环境
conda activate unilab
# 运行验证脚本
python verify_installation.py
```
如果看到 "✓ All checks passed!",说明安装成功!
## 常见问题
### Q: 安装脚本找不到 conda
**A:** 确保你已经安装了 conda/miniconda/miniforge并且安装在标准位置
- **Windows**:
- `%USERPROFILE%\miniforge3`
- `%USERPROFILE%\miniconda3`
- `%USERPROFILE%\anaconda3`
- `C:\ProgramData\miniforge3`
- **macOS/Linux**:
- `~/miniforge3`
- `~/miniconda3`
- `~/anaconda3`
- `/opt/conda`
如果安装在其他位置,可以先激活 conda base 环境,然后手动运行安装脚本。
### Q: 安装后激活环境提示找不到?
**A:** 尝试以下方法:
```bash
# 方法 1: 使用 conda activate
conda activate unilab
# 方法 2: 使用完整路径激活Windows
call C:\Users\{YourUsername}\miniforge3\envs\unilab\Scripts\activate.bat
# 方法 2: 使用完整路径激活Unix
source ~/miniforge3/envs/unilab/bin/activate
```
### Q: conda-unpack 失败怎么办?
**A:** 尝试手动运行:
```bash
# Windows
cd %CONDA_PREFIX%\envs\unilab
.\Scripts\conda-unpack.exe
# macOS/Linux
cd $CONDA_PREFIX/envs/unilab
./bin/conda-unpack
```
### Q: 验证脚本报错?
**A:** 首先确认环境已激活:
```bash
# 检查当前环境
conda env list
# 应该看到 unilab 前面有 * 标记
```
如果仍有问题,查看具体报错信息,可能需要:
- 重新运行安装脚本
- 检查磁盘空间
- 查看详细文档
### Q: 环境很大,有办法减小吗?
**A:** 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB。这是为了确保离线安装和完整功能。如果空间有限考虑使用手动安装方式只安装需要的组件。
### Q: 如何更新到最新版本?
**A:** 重新下载最新的预打包环境,运行安装脚本时选择覆盖现有环境。
或者在现有环境中更新:
```bash
conda activate unilab
# 更新 unilabos
cd /path/to/Uni-Lab-OS
git pull
pip install -e . --upgrade
# 更新 ros-humble-unilabos-msgs
mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge
```
## 下一步
安装完成后,你可以:
1. **查看启动指南**: {doc}`launch`
2. **运行示例**: {doc}`../boot_examples/index`
3. **配置设备**: 编辑 `unilabos_data/startup_config.json`
4. **阅读开发文档**: {doc}`../developer_guide/workstation_architecture`
## 需要帮助?
- **文档**: [docs/user_guide/installation.md](installation.md)
- **问题反馈**: [GitHub Issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)
- **开发版安装**: 参考 {doc}`installation` 的方式二
---
**提示**: 这个预打包环境包含了从指定分支(通常是 `dev`)构建的最新代码。如果需要稳定版本,请使用方式二手动安装 release 版本。

View File

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

View File

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

View File

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

View File

0
tests/ros/__init__.py Normal file
View File

View File

View File

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 148 KiB

View File

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View File

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

View File

@@ -0,0 +1,35 @@
import sys
from datetime import datetime
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
import pytest
from unilabos.workflow.convert_from_json import (
convert_from_json,
normalize_steps as _normalize_steps,
normalize_labware as _normalize_labware,
)
from unilabos.workflow.common import draw_protocol_graph_with_ports
@pytest.mark.parametrize(
"protocol_name",
[
"example_bio",
# "bioyond_materials_liquidhandling_1",
"example_prcxi",
],
)
def test_build_protocol_graph(protocol_name):
data_path = Path(__file__).with_name(f"{protocol_name}.json")
graph = convert_from_json(data_path, workstation_name="PRCXi")
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
output_path = data_path.with_name(f"{protocol_name}_graph_{timestamp}.png")
draw_protocol_graph_with_ports(graph, str(output_path))
print(graph)

View File

@@ -1 +1 @@
__version__ = "0.10.10"
__version__ = "0.10.13"

View File

@@ -141,7 +141,7 @@ class CommunicationClientFactory:
"""
if cls._client_cache is None:
cls._client_cache = cls.create_client(protocol)
logger.info(f"[CommunicationFactory] Created {type(cls._client_cache).__name__} client")
logger.trace(f"[CommunicationFactory] Created {type(cls._client_cache).__name__} client")
return cls._client_cache

View File

@@ -105,7 +105,7 @@ def parse_args():
parser.add_argument(
"--port",
type=int,
default=8002,
default=None,
help="Port for web service information page",
)
parser.add_argument(
@@ -159,9 +159,10 @@ def parse_args():
def main():
"""主函数"""
# 解析命令行参数
args = parse_args()
convert_argv_dashes_to_underscores(args)
args_dict = vars(args.parse_args())
parser = parse_args()
convert_argv_dashes_to_underscores(parser)
args = parser.parse_args()
args_dict = vars(args)
# 环境检查 - 检查并自动安装必需的包 (可选)
if not args_dict.get("skip_env_check", False):
@@ -218,19 +219,20 @@ def main():
if hasattr(BasicConfig, "log_level"):
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
configure_logger(loglevel=BasicConfig.log_level)
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
if args_dict["addr"] == "test":
print_status("使用测试环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
elif args_dict["addr"] == "uat":
print_status("使用uat环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
elif args_dict["addr"] == "local":
print_status("使用本地环境地址", "info")
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
else:
HTTPConfig.remote_addr = args_dict.get("addr", "")
if args.addr != parser.get_default("addr"):
if args.addr == "test":
print_status("使用测试环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
elif args.addr == "uat":
print_status("使用uat环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
elif args.addr == "local":
print_status("使用本地环境地址", "info")
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
else:
HTTPConfig.remote_addr = args.addr
# 设置BasicConfig参数
if args_dict.get("ak", ""):
@@ -252,6 +254,8 @@ def main():
else:
print_status("远程资源不存在,本地将进行首次上报!", "info")
BasicConfig.port = args_dict["port"] if args_dict["port"] else BasicConfig.port
BasicConfig.disable_browser = args_dict["disable_browser"] or BasicConfig.disable_browser
BasicConfig.working_dir = working_dir
BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
@@ -291,7 +295,9 @@ def main():
resource_tree_set: ResourceTreeSet
resource_links: List[Dict[str, Any]]
request_startup_json = http_client.request_startup_json()
if args_dict["graph"] is None:
file_path = args_dict.get("graph", BasicConfig.startup_json_path)
if file_path is None:
if not request_startup_json:
print_status(
"未指定设备加载文件路径尝试从HTTP获取失败请检查网络或者使用-g参数指定设备加载文件路径", "error"
@@ -301,7 +307,11 @@ def main():
print_status("联网获取设备加载文件成功", "info")
graph, resource_tree_set, resource_links = read_node_link_json(request_startup_json)
else:
file_path = args_dict["graph"]
if not os.path.isfile(file_path):
temp_file_path = os.path.abspath(str(os.path.join(__file__, "..", "..", file_path)))
if os.path.isfile(temp_file_path):
print_status(f"使用相对路径{temp_file_path}", "info")
file_path = temp_file_path
if file_path.endswith(".json"):
graph, resource_tree_set, resource_links = read_node_link_json(file_path)
else:
@@ -319,6 +329,10 @@ def main():
for ind, i in enumerate(resource_edge_info[::-1]):
source_node: ResourceDict = nodes[i["source"]]
target_node: ResourceDict = nodes[i["target"]]
if "sourceHandle" not in source_node:
continue
if "targetHandle" not in target_node:
continue
source_handle = i["sourceHandle"]
target_handle = i["targetHandle"]
source_handler_keys = [
@@ -413,8 +427,8 @@ def main():
server_thread = threading.Thread(
target=start_server,
kwargs=dict(
open_browser=not args_dict["disable_browser"],
port=args_dict["port"],
open_browser=not BasicConfig.disable_browser,
port=BasicConfig.port,
),
)
server_thread.start()
@@ -442,13 +456,13 @@ def main():
start_backend(**args_dict)
start_server(
open_browser=not args_dict["disable_browser"],
port=args_dict["port"],
port=BasicConfig.port,
)
else:
start_backend(**args_dict)
start_server(
open_browser=not args_dict["disable_browser"],
port=args_dict["port"],
port=BasicConfig.port,
)

View File

@@ -51,21 +51,25 @@ class Resp(BaseModel):
class JobAddReq(BaseModel):
device_id: str = Field(examples=["Gripper"], description="device id")
action: str = Field(examples=["_execute_driver_command_async"], description="action name", default="")
action_type: str = Field(examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action name", default="")
action_args: dict = Field(examples=[{'string': 'string'}], description="action name", default="")
task_id: str = Field(examples=["task_id"], description="task uuid")
job_id: str = Field(examples=["job_id"], description="goal uuid")
node_id: str = Field(examples=["node_id"], description="node uuid")
server_info: dict = Field(examples=[{"send_timestamp": 1717000000.0}], description="server info")
action_type: str = Field(
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
)
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
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="")
node_id: str = Field(examples=["node_id"], description="node uuid", default="")
server_info: dict = Field(
examples=[{"send_timestamp": 1717000000.0}],
description="server info (auto-generated if empty)",
default_factory=dict,
)
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}], default={})
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}], default_factory=dict)
class JobStepFinishReq(BaseModel):
token: str = Field(examples=["030944"], description="token")
request_time: str = Field(
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
)
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
data: dict = Field(
examples=[
{
@@ -83,9 +87,7 @@ class JobStepFinishReq(BaseModel):
class JobPreintakeFinishReq(BaseModel):
token: str = Field(examples=["030944"], description="token")
request_time: str = Field(
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
)
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
data: dict = Field(
examples=[
{
@@ -102,9 +104,7 @@ class JobPreintakeFinishReq(BaseModel):
class JobFinishReq(BaseModel):
token: str = Field(examples=["030944"], description="token")
request_time: str = Field(
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
)
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
data: dict = Field(
examples=[
{
@@ -133,6 +133,10 @@ class JobData(BaseModel):
default=0,
description="0:UNKNOWN, 1:ACCEPTED, 2:EXECUTING, 3:CANCELING, 4:SUCCEEDED, 5:CANCELED, 6:ABORTED",
)
result: dict = Field(
default_factory=dict,
description="Job result data (available when status is SUCCEEDED/CANCELED/ABORTED)",
)
class JobStatusResp(Resp):

View File

@@ -1,161 +1,158 @@
import argparse
import os
import time
from typing import Dict, Optional, Tuple
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional, Tuple, Union
import requests
from unilabos.config.config import OSSUploadConfig
from unilabos.app.web.client import http_client, HTTPClient
from unilabos.utils import logger
def _init_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
process_key: str = "file-upload", device_id: str = "default",
expires_hours: int = 1) -> Tuple[bool, Dict]:
def _get_oss_token(
filename: str,
driver_name: str = "default",
exp_type: str = "default",
client: Optional[HTTPClient] = None,
) -> Tuple[bool, Dict]:
"""
初始化上传过程
获取OSS上传Token
Args:
file_path: 本地文件路径
oss_path: OSS目标路径
filename: 文件名如果为None则使用file_path的文件名
process_key: 处理键
device_id: 设备ID
expires_hours: 链接过期小时数
filename: 文件名
driver_name: 驱动名称
exp_type: 实验类型
client: HTTPClient实例如果不提供则使用默认的http_client
Returns:
(成功标志, 响应数据)
(成功标志, Token数据字典包含token/path/host/expires)
"""
if filename is None:
filename = os.path.basename(file_path)
# 使用提供的client或默认的http_client
if client is None:
client = http_client
# 构造初始化请求
url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.init_endpoint}"
headers = {
"Authorization": OSSUploadConfig.authorization,
"Content-Type": "application/json"
}
# 构造scene参数: driver_name-exp_type
sub_path = f"{driver_name}-{exp_type}"
payload = {
"device_id": device_id,
"process_key": process_key,
"filename": filename,
"path": oss_path,
"expires_hours": expires_hours
}
# 构造请求URL使用client的remote_addr已包含/api/v1/
url = f"{client.remote_addr}/applications/token"
params = {"sub_path": sub_path, "filename": filename, "scene": "job"}
try:
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 201:
result = response.json()
if result.get("code") == "10000":
return True, result.get("data", {})
logger.info(f"[OSS] 请求预签名URL: sub_path={sub_path}, filename={filename}")
response = requests.get(url, params=params, headers={"Authorization": f"Lab {client.auth}"}, timeout=10)
print(f"初始化上传失败: {response.status_code}, {response.text}")
if response.status_code == 200:
result = response.json()
if result.get("code") == 0:
data = result.get("data", {})
# 转换expires时间戳为可读格式
expires_timestamp = data.get("expires", 0)
expires_datetime = datetime.fromtimestamp(expires_timestamp)
expires_str = expires_datetime.strftime("%Y-%m-%d %H:%M:%S")
logger.info(f"[OSS] 获取预签名URL成功")
logger.info(f"[OSS] - URL: {data.get('url', 'N/A')}")
logger.info(f"[OSS] - Expires: {expires_str} (timestamp: {expires_timestamp})")
return True, data
logger.error(f"[OSS] 获取预签名URL失败: {response.status_code}, {response.text}")
return False, {}
except Exception as e:
print(f"初始化上传异常: {str(e)}")
logger.error(f"[OSS] 获取预签名URL异常: {str(e)}")
return False, {}
def _put_upload(file_path: str, upload_url: str) -> bool:
"""
执行PUT上传
使用预签名URL上传文件到OSS
Args:
file_path: 本地文件路径
upload_url: 上传URL
upload_url: 完整的预签名上传URL
Returns:
是否成功
"""
try:
logger.info(f"[OSS] 开始上传文件: {file_path}")
with open(file_path, "rb") as f:
response = requests.put(upload_url, data=f)
# 使用预签名URL上传不需要额外的认证header
response = requests.put(upload_url, data=f, timeout=300)
if response.status_code == 200:
logger.info(f"[OSS] 文件上传成功")
return True
print(f"PUT上传失败: {response.status_code}, {response.text}")
logger.error(f"[OSS] 上传失败: {response.status_code}")
logger.error(f"[OSS] 响应内容: {response.text[:500] if response.text else '无响应内容'}")
return False
except Exception as e:
print(f"PUT上传异常: {str(e)}")
logger.error(f"[OSS] 上传异常: {str(e)}")
return False
def _complete_upload(uuid: str) -> bool:
"""
完成上传过程
Args:
uuid: 上传的UUID
Returns:
是否成功
"""
url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.complete_endpoint}"
headers = {
"Authorization": OSSUploadConfig.authorization,
"Content-Type": "application/json"
}
payload = {
"uuid": uuid
}
try:
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 200:
result = response.json()
if result.get("code") == "10000":
return True
print(f"完成上传失败: {response.status_code}, {response.text}")
return False
except Exception as e:
print(f"完成上传异常: {str(e)}")
return False
def oss_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
process_key: str = "file-upload", device_id: str = "default") -> bool:
def oss_upload(
file_path: Union[str, Path],
filename: Optional[str] = None,
driver_name: str = "default",
exp_type: str = "default",
max_retries: int = 3,
client: Optional[HTTPClient] = None,
) -> Dict:
"""
文件上传主函数,包含重试机制
Args:
file_path: 本地文件路径
oss_path: OSS目标路径
filename: 文件名如果为None则使用file_path的文件名
process_key: 处理键
device_id: 设备ID
driver_name: 驱动名称用于构造scene
exp_type: 实验类型用于构造scene
max_retries: 最大重试次数
client: HTTPClient实例如果不提供则使用默认的http_client
Returns:
是否成功上传
Dict: {
"success": bool, # 是否上传成功
"original_path": str, # 原始文件路径
"oss_path": str # OSS路径成功时或空字符串失败时
}
"""
max_retries = OSSUploadConfig.max_retries
file_path = Path(file_path)
if filename is None:
filename = os.path.basename(file_path)
if not os.path.exists(file_path):
logger.error(f"[OSS] 文件不存在: {file_path}")
return {"success": False, "original_path": file_path, "oss_path": ""}
retry_count = 0
oss_path = ""
while retry_count < max_retries:
try:
# 步骤1初始化上传
init_success, init_data = _init_upload(
file_path=file_path,
oss_path=oss_path,
filename=filename,
process_key=process_key,
device_id=device_id
# 步骤1获取预签名URL
token_success, token_data = _get_oss_token(
filename=filename, driver_name=driver_name, exp_type=exp_type, client=client
)
if not init_success:
print(f"初始化上传失败,重试 {retry_count + 1}/{max_retries}")
if not token_success:
logger.warning(f"[OSS] 获取预签名URL失败,重试 {retry_count + 1}/{max_retries}")
retry_count += 1
time.sleep(1) # 等待1秒后重试
time.sleep(1)
continue
# 获取UUID和上传URL
uuid = init_data.get("uuid")
upload_url = init_data.get("upload_url")
# 获取预签名URL和OSS路径
upload_url = token_data.get("url")
oss_path = token_data.get("path", "")
if not uuid or not upload_url:
print(f"初始化上传返回数据不完整,重试 {retry_count + 1}/{max_retries}")
if not upload_url:
logger.warning(f"[OSS] 无法获取上传URLAPI未返回url字段")
retry_count += 1
time.sleep(1)
continue
@@ -163,69 +160,82 @@ def oss_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
# 步骤2PUT上传文件
put_success = _put_upload(file_path, upload_url)
if not put_success:
print(f"PUT上传失败重试 {retry_count + 1}/{max_retries}")
retry_count += 1
time.sleep(1)
continue
# 步骤3完成上传
complete_success = _complete_upload(uuid)
if not complete_success:
print(f"完成上传失败,重试 {retry_count + 1}/{max_retries}")
logger.warning(f"[OSS] PUT上传失败重试 {retry_count + 1}/{max_retries}")
retry_count += 1
time.sleep(1)
continue
# 所有步骤都成功
print(f"文件 {file_path} 上传成功")
return True
logger.info(f"[OSS] 文件 {file_path} 上传成功")
return {"success": True, "original_path": file_path, "oss_path": oss_path}
except Exception as e:
print(f"上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}")
logger.error(f"[OSS] 上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}")
retry_count += 1
time.sleep(1)
print(f"文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}")
return False
logger.error(f"[OSS] 文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}")
return {"success": False, "original_path": file_path, "oss_path": oss_path}
if __name__ == "__main__":
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt --driver HPLC --type test
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt --driver HPLC --type test \
# --ak xxx --sk yyy --remote-addr http://xxx/api/v1
# 命令行参数解析
parser = argparse.ArgumentParser(description='文件上传测试工具')
parser.add_argument('--file', '-f', type=str, required=True, help='要上传的本地文件路径')
parser.add_argument('--path', '-p', type=str, default='/HPLC1/Any', help='OSS目标路径')
parser.add_argument('--device', '-d', type=str, default='test-device', help='设备ID')
parser.add_argument('--process', '-k', type=str, default='HPLC-txt-result', help='处理键')
parser = argparse.ArgumentParser(description="文件上传测试工具")
parser.add_argument("--file", "-f", type=str, required=True, help="要上传的本地文件路径")
parser.add_argument("--driver", "-d", type=str, default="default", help="驱动名称")
parser.add_argument("--type", "-t", type=str, default="default", help="实验类型")
parser.add_argument("--ak", type=str, help="Access Key如果提供则覆盖配置")
parser.add_argument("--sk", type=str, help="Secret Key如果提供则覆盖配置")
parser.add_argument("--remote-addr", type=str, help="远程服务器地址(包含/api/v1如果提供则覆盖配置")
args = parser.parse_args()
# 检查文件是否存在
if not os.path.exists(args.file):
print(f"错误:文件 {args.file} 不存在")
logger.error(f"错误:文件 {args.file} 不存在")
exit(1)
print("=" * 50)
print(f"开始上传文件: {args.file}")
print(f"目标路径: {args.path}")
print(f"设备ID: {args.device}")
print(f"处理键: {args.process}")
print("=" * 50)
# 如果提供了ak/sk/remote_addr创建临时HTTPClient
temp_client = None
if args.ak and args.sk:
import base64
auth = base64.b64encode(f"{args.ak}:{args.sk}".encode("utf-8")).decode("utf-8")
remote_addr = args.remote_addr if args.remote_addr else http_client.remote_addr
temp_client = HTTPClient(remote_addr=remote_addr, auth=auth)
logger.info(f"[配置] 使用自定义配置: remote_addr={remote_addr}")
elif args.remote_addr:
temp_client = HTTPClient(remote_addr=args.remote_addr, auth=http_client.auth)
logger.info(f"[配置] 使用自定义remote_addr: {args.remote_addr}")
else:
logger.info(f"[配置] 使用默认配置: remote_addr={http_client.remote_addr}")
logger.info("=" * 50)
logger.info(f"开始上传文件: {args.file}")
logger.info(f"驱动名称: {args.driver}")
logger.info(f"实验类型: {args.type}")
logger.info(f"Scene: {args.driver}-{args.type}")
logger.info("=" * 50)
# 执行上传
success = oss_upload(
result = oss_upload(
file_path=args.file,
oss_path=args.path,
filename=None, # 使用默认文件名
process_key=args.process,
device_id=args.device
driver_name=args.driver,
exp_type=args.type,
client=temp_client,
)
# 输出结果
if success:
print("\n√ 文件上传成功!")
if result["success"]:
logger.info(f"\n√ 文件上传成功!")
logger.info(f"原始路径: {result['original_path']}")
logger.info(f"OSS路径: {result['oss_path']}")
exit(0)
else:
print("\n× 文件上传失败!")
logger.error(f"\n× 文件上传失败!")
logger.error(f"原始路径: {result['original_path']}")
exit(1)

View File

@@ -9,13 +9,22 @@ import asyncio
import yaml
from unilabos.app.web.controler import devices, job_add, job_info
from unilabos.app.web.controller import (
devices,
job_add,
job_info,
get_online_devices,
get_device_actions,
get_action_schema,
get_all_available_actions,
)
from unilabos.app.model import (
Resp,
RespCode,
JobStatusResp,
JobAddResp,
JobAddReq,
JobData,
)
from unilabos.app.web.utils.host_utils import get_host_node_info
from unilabos.registry.registry import lab_registry
@@ -1234,6 +1243,65 @@ def get_devices():
return Resp(data=dict(data))
@api.get("/online-devices", summary="Online devices list", response_model=Resp)
def api_get_online_devices():
"""获取在线设备列表
返回当前在线的设备列表包含设备ID、命名空间、机器名等信息
"""
isok, data = get_online_devices()
if not isok:
return Resp(code=RespCode.ErrorHostNotInit, message=data.get("error", "Unknown error"))
return Resp(data=data)
@api.get("/devices/{device_id}/actions", summary="Device actions list", response_model=Resp)
def api_get_device_actions(device_id: str):
"""获取设备可用的动作列表
Args:
device_id: 设备ID
返回指定设备的所有可用动作,包含动作名称、类型、是否繁忙等信息
"""
isok, data = get_device_actions(device_id)
if not isok:
return Resp(code=RespCode.ErrorInvalidReq, message=data.get("error", "Unknown error"))
return Resp(data=data)
@api.get("/devices/{device_id}/actions/{action_name}/schema", summary="Action schema", response_model=Resp)
def api_get_action_schema(device_id: str, action_name: str):
"""获取动作的Schema详情
Args:
device_id: 设备ID
action_name: 动作名称
返回动作的参数Schema、默认值、类型等详细信息
"""
isok, data = get_action_schema(device_id, action_name)
if not isok:
return Resp(code=RespCode.ErrorInvalidReq, message=data.get("error", "Unknown error"))
return Resp(data=data)
@api.get("/actions", summary="All available actions", response_model=Resp)
def api_get_all_actions():
"""获取所有设备的可用动作
返回所有已注册设备的动作列表,包含设备信息和各动作的状态
"""
isok, data = get_all_available_actions()
if not isok:
return Resp(code=RespCode.ErrorHostNotInit, message=data.get("error", "Unknown error"))
return Resp(data=data)
@api.get("/job/{id}/status", summary="Job status", response_model=JobStatusResp)
def job_status(id: str):
"""获取任务状态"""
@@ -1244,11 +1312,22 @@ def job_status(id: str):
@api.post("/job/add", summary="Create job", response_model=JobAddResp)
def post_job_add(req: JobAddReq):
"""创建任务"""
device_id = req.device_id
if not req.data:
return Resp(code=RespCode.ErrorInvalidReq, message="Invalid request data")
# 检查必要参数device_id 和 action
if not req.device_id:
return JobAddResp(
data=JobData(jobId="", status=6),
code=RespCode.ErrorInvalidReq,
message="device_id is required",
)
action_name = req.data.get("action", req.action) if req.data else req.action
if not action_name:
return JobAddResp(
data=JobData(jobId="", status=6),
code=RespCode.ErrorInvalidReq,
message="action is required",
)
req.device_id = device_id
data = job_add(req)
return JobAddResp(data=data)

View File

@@ -299,6 +299,10 @@ class HTTPClient:
)
if response.status_code not in [200, 201]:
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
if response.status_code == 200:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"注册资源失败: {response.text}")
return response
def request_startup_json(self) -> Optional[Dict[str, Any]]:

View File

@@ -1,45 +0,0 @@
import json
import traceback
import uuid
from unilabos.app.model import JobAddReq, JobData
from unilabos.ros.nodes.presets.host_node import HostNode
from unilabos.utils.type_check import serialize_result_info
def get_resources() -> tuple:
if HostNode.get_instance() is None:
return False, "Host node not initialized"
return True, HostNode.get_instance().resources_config
def devices() -> tuple:
if HostNode.get_instance() is None:
return False, "Host node not initialized"
return True, HostNode.get_instance().devices_config
def job_info(id: str):
get_goal_status = HostNode.get_instance().get_goal_status(id)
return JobData(jobId=id, status=get_goal_status)
def job_add(req: JobAddReq) -> JobData:
if req.job_id is None:
req.job_id = str(uuid.uuid4())
action_name = req.data["action"]
action_type = req.data.get("action_type", "LocalUnknown")
action_args = req.data.get("action_kwargs", None) # 兼容老版本,后续删除
if action_args is None:
action_args = req.data.get("action_args")
else:
if "command" in action_args:
action_args = action_args["command"]
# print(f"job_add:{req.device_id} {action_name} {action_kwargs}")
try:
HostNode.get_instance().send_goal(req.device_id, action_type=action_type, action_name=action_name, action_kwargs=action_args, goal_uuid=req.job_id, server_info=req.server_info)
except Exception as e:
for bridge in HostNode.get_instance().bridges:
traceback.print_exc()
if hasattr(bridge, "publish_job_status"):
bridge.publish_job_status({}, req.job_id, "failed", serialize_result_info(traceback.format_exc(), False, {}))
return JobData(jobId=req.job_id)

View File

@@ -0,0 +1,587 @@
"""
Web API Controller
提供Web API的控制器函数处理设备、任务和动作相关的业务逻辑
"""
import threading
import time
import traceback
import uuid
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, Tuple
from unilabos.app.model import JobAddReq, JobData
from unilabos.ros.nodes.presets.host_node import HostNode
from unilabos.utils import logger
@dataclass
class JobResult:
"""任务结果数据"""
job_id: str
status: int # 4:SUCCEEDED, 5:CANCELED, 6:ABORTED
result: Dict[str, Any] = field(default_factory=dict)
feedback: Dict[str, Any] = field(default_factory=dict)
timestamp: float = field(default_factory=time.time)
class JobResultStore:
"""任务结果存储(单例)"""
_instance: Optional["JobResultStore"] = None
_lock = threading.Lock()
def __init__(self):
if not hasattr(self, "_initialized"):
self._results: Dict[str, JobResult] = {}
self._results_lock = threading.RLock()
self._initialized = True
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def store_result(
self, job_id: str, status: int, result: Optional[Dict[str, Any]], feedback: Optional[Dict[str, Any]] = None
):
"""存储任务结果"""
with self._results_lock:
self._results[job_id] = JobResult(
job_id=job_id,
status=status,
result=result or {},
feedback=feedback or {},
timestamp=time.time(),
)
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
"""获取并删除任务结果"""
with self._results_lock:
result = self._results.pop(job_id, None)
if result:
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
return result
def get_result(self, job_id: str) -> Optional[JobResult]:
"""仅获取任务结果(不删除)"""
with self._results_lock:
return self._results.get(job_id)
def cleanup_old_results(self, max_age_seconds: float = 3600):
"""清理过期的结果"""
current_time = time.time()
with self._results_lock:
expired_jobs = [
job_id for job_id, result in self._results.items() if current_time - result.timestamp > max_age_seconds
]
for job_id in expired_jobs:
del self._results[job_id]
logger.debug(f"[JobResultStore] Cleaned up expired result for job {job_id[:8]}")
# 全局结果存储实例
job_result_store = JobResultStore()
def store_job_result(
job_id: str, status: str, result: Optional[Dict[str, Any]], feedback: Optional[Dict[str, Any]] = None
):
"""存储任务结果(供外部调用)
Args:
job_id: 任务ID
status: 状态字符串 ("success", "failed", "cancelled")
result: 结果数据
feedback: 反馈数据
"""
# 转换状态字符串为整数
status_map = {
"success": 4, # SUCCEEDED
"failed": 6, # ABORTED
"cancelled": 5, # CANCELED
"running": 2, # EXECUTING
}
status_int = status_map.get(status, 0)
# 只存储最终状态
if status_int in (4, 5, 6):
job_result_store.store_result(job_id, status_int, result, feedback)
def get_resources() -> Tuple[bool, Any]:
"""获取资源配置
Returns:
Tuple[bool, Any]: (是否成功, 资源配置或错误信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, "Host node not initialized"
return True, host_node.resources_config
def devices() -> Tuple[bool, Any]:
"""获取设备配置
Returns:
Tuple[bool, Any]: (是否成功, 设备配置或错误信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, "Host node not initialized"
return True, host_node.devices_config
def job_info(job_id: str, remove_after_read: bool = True) -> JobData:
"""获取任务信息
Args:
job_id: 任务ID
remove_after_read: 是否在读取后删除结果默认True
Returns:
JobData: 任务数据
"""
# 首先检查结果存储中是否有已完成的结果
if remove_after_read:
stored_result = job_result_store.get_and_remove(job_id)
else:
stored_result = job_result_store.get_result(job_id)
if stored_result:
# 有存储的结果,直接返回
return JobData(
jobId=job_id,
status=stored_result.status,
result=stored_result.result,
)
# 没有存储的结果,从 HostNode 获取当前状态
host_node = HostNode.get_instance(0)
if host_node is None:
return JobData(jobId=job_id, status=0)
get_goal_status = host_node.get_goal_status(job_id)
return JobData(jobId=job_id, status=get_goal_status)
def check_device_action_busy(device_id: str, action_name: str) -> Tuple[bool, Optional[str]]:
"""检查设备动作是否正在执行(被占用)
Args:
device_id: 设备ID
action_name: 动作名称
Returns:
Tuple[bool, Optional[str]]: (是否繁忙, 当前执行的job_id或None)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, None
device_action_key = f"/devices/{device_id}/{action_name}"
# 检查 _device_action_status 中是否有正在执行的任务
if device_action_key in host_node._device_action_status:
status = host_node._device_action_status[device_action_key]
if status.job_ids:
# 返回第一个正在执行的job_id
current_job_id = next(iter(status.job_ids.keys()), None)
return True, current_job_id
return False, None
def _get_action_type(device_id: str, action_name: str) -> Optional[str]:
"""从注册表自动获取动作类型
Args:
device_id: 设备ID
action_name: 动作名称
Returns:
动作类型字符串未找到返回None
"""
try:
from unilabos.ros.nodes.base_device_node import registered_devices
# 方法1: 从运行时注册设备获取
if device_id in registered_devices:
device_info = registered_devices[device_id]
base_node = device_info.get("base_node_instance")
if base_node and hasattr(base_node, "_action_value_mappings"):
action_mappings = base_node._action_value_mappings
# 尝试直接匹配或 auto- 前缀匹配
for key in [action_name, f"auto-{action_name}"]:
if key in action_mappings:
action_type = action_mappings[key].get("type")
if action_type:
# 转换为字符串格式
if hasattr(action_type, "__module__") and hasattr(action_type, "__name__"):
return f"{action_type.__module__}.{action_type.__name__}"
return str(action_type)
# 方法2: 从lab_registry获取
from unilabos.registry.registry import lab_registry
host_node = HostNode.get_instance(0)
if host_node and lab_registry:
devices_config = host_node.devices_config
device_class = None
for tree in devices_config.trees:
node = tree.root_node
if node.res_content.id == device_id:
device_class = node.res_content.klass
break
if device_class and device_class in lab_registry.device_type_registry:
device_type_info = lab_registry.device_type_registry[device_class]
class_info = device_type_info.get("class", {})
action_mappings = class_info.get("action_value_mappings", {})
for key in [action_name, f"auto-{action_name}"]:
if key in action_mappings:
action_type = action_mappings[key].get("type")
if action_type:
if hasattr(action_type, "__module__") and hasattr(action_type, "__name__"):
return f"{action_type.__module__}.{action_type.__name__}"
return str(action_type)
except Exception as e:
logger.warning(f"[Controller] Failed to get action type for {device_id}/{action_name}: {str(e)}")
return None
def job_add(req: JobAddReq) -> JobData:
"""添加任务(检查设备是否繁忙,繁忙则返回失败)
Args:
req: 任务添加请求
Returns:
JobData: 任务数据(包含状态)
"""
# 服务端自动生成 job_id 和 task_id
job_id = str(uuid.uuid4())
task_id = str(uuid.uuid4())
# 服务端自动生成 server_info
server_info = {"send_timestamp": time.time()}
host_node = HostNode.get_instance(0)
if host_node is None:
logger.error(f"[Controller] Host node not initialized for job: {job_id[:8]}")
return JobData(jobId=job_id, status=6) # 6 = ABORTED
# 解析动作信息
action_name = req.data.get("action", req.action) if req.data else req.action
action_args = req.data.get("action_kwargs") or req.data.get("action_args") if req.data else req.action_args
if action_args is None:
action_args = req.action_args or {}
elif isinstance(action_args, dict) and "command" in action_args:
action_args = action_args["command"]
# 自动获取 action_type
action_type = _get_action_type(req.device_id, action_name)
if action_type is None:
logger.error(f"[Controller] Action type not found for {req.device_id}/{action_name}")
return JobData(jobId=job_id, status=6) # ABORTED
# 检查设备动作是否繁忙
is_busy, current_job_id = check_device_action_busy(req.device_id, action_name)
if is_busy:
logger.warning(
f"[Controller] Device action busy: {req.device_id}/{action_name}, "
f"current job: {current_job_id[:8] if current_job_id else 'unknown'}"
)
# 返回失败状态status=6 表示 ABORTED
return JobData(jobId=job_id, status=6)
# 设备空闲,提交任务执行
try:
from unilabos.app.ws_client import QueueItem
device_action_key = f"/devices/{req.device_id}/{action_name}"
queue_item = QueueItem(
task_type="job_call_back_status",
device_id=req.device_id,
action_name=action_name,
task_id=task_id,
job_id=job_id,
device_action_key=device_action_key,
)
host_node.send_goal(
queue_item,
action_type=action_type,
action_kwargs=action_args,
server_info=server_info,
)
logger.info(f"[Controller] Job submitted: {job_id[:8]} -> {req.device_id}/{action_name}")
# 返回已接受状态status=1 表示 ACCEPTED
return JobData(jobId=job_id, status=1)
except ValueError as e:
# ActionClient not found 等错误
logger.error(f"[Controller] Action not available: {str(e)}")
return JobData(jobId=job_id, status=6) # ABORTED
except Exception as e:
logger.error(f"[Controller] Error submitting job: {str(e)}")
traceback.print_exc()
return JobData(jobId=job_id, status=6) # ABORTED
def get_online_devices() -> Tuple[bool, Dict[str, Any]]:
"""获取在线设备列表
Returns:
Tuple[bool, Dict]: (是否成功, 在线设备信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, {"error": "Host node not initialized"}
try:
from unilabos.ros.nodes.base_device_node import registered_devices
online_devices = {}
for device_key in host_node._online_devices:
# device_key 格式: "namespace/device_id"
parts = device_key.split("/")
if len(parts) >= 2:
device_id = parts[-1]
else:
device_id = device_key
# 获取设备详细信息
device_info = registered_devices.get(device_id, {})
machine_name = host_node.device_machine_names.get(device_id, "未知")
online_devices[device_id] = {
"device_key": device_key,
"namespace": host_node.devices_names.get(device_id, ""),
"machine_name": machine_name,
"uuid": device_info.get("uuid", "") if device_info else "",
"node_name": device_info.get("node_name", "") if device_info else "",
}
return True, {
"online_devices": online_devices,
"total_count": len(online_devices),
"timestamp": time.time(),
}
except Exception as e:
logger.error(f"[Controller] Error getting online devices: {str(e)}")
traceback.print_exc()
return False, {"error": str(e)}
def get_device_actions(device_id: str) -> Tuple[bool, Dict[str, Any]]:
"""获取设备可用的动作列表
Args:
device_id: 设备ID
Returns:
Tuple[bool, Dict]: (是否成功, 动作列表信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, {"error": "Host node not initialized"}
try:
from unilabos.ros.nodes.base_device_node import registered_devices
from unilabos.app.web.utils.action_utils import get_action_info
# 检查设备是否已注册
if device_id not in registered_devices:
return False, {"error": f"Device not found: {device_id}"}
device_info = registered_devices[device_id]
actions = device_info.get("actions", {})
actions_list = {}
for action_name, action_server in actions.items():
try:
action_info = get_action_info(action_server, action_name)
# 检查动作是否繁忙
is_busy, current_job = check_device_action_busy(device_id, action_name)
actions_list[action_name] = {
**action_info,
"is_busy": is_busy,
"current_job_id": current_job[:8] if current_job else None,
}
except Exception as e:
logger.warning(f"[Controller] Error getting action info for {action_name}: {str(e)}")
actions_list[action_name] = {
"type_name": "unknown",
"action_path": f"/devices/{device_id}/{action_name}",
"is_busy": False,
"error": str(e),
}
return True, {
"device_id": device_id,
"actions": actions_list,
"action_count": len(actions_list),
}
except Exception as e:
logger.error(f"[Controller] Error getting device actions: {str(e)}")
traceback.print_exc()
return False, {"error": str(e)}
def get_action_schema(device_id: str, action_name: str) -> Tuple[bool, Dict[str, Any]]:
"""获取动作的Schema详情
Args:
device_id: 设备ID
action_name: 动作名称
Returns:
Tuple[bool, Dict]: (是否成功, Schema信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, {"error": "Host node not initialized"}
try:
from unilabos.registry.registry import lab_registry
from unilabos.ros.nodes.base_device_node import registered_devices
result = {
"device_id": device_id,
"action_name": action_name,
"schema": None,
"goal_default": None,
"action_type": None,
"is_busy": False,
}
# 检查动作是否繁忙
is_busy, current_job = check_device_action_busy(device_id, action_name)
result["is_busy"] = is_busy
result["current_job_id"] = current_job[:8] if current_job else None
# 方法1: 从 registered_devices 获取运行时信息
if device_id in registered_devices:
device_info = registered_devices[device_id]
base_node = device_info.get("base_node_instance")
if base_node and hasattr(base_node, "_action_value_mappings"):
action_mappings = base_node._action_value_mappings
if action_name in action_mappings:
mapping = action_mappings[action_name]
result["schema"] = mapping.get("schema")
result["goal_default"] = mapping.get("goal_default")
result["action_type"] = str(mapping.get("type", ""))
# 方法2: 从 lab_registry 获取注册表信息(如果运行时没有)
if result["schema"] is None and lab_registry:
# 尝试查找设备类型
devices_config = host_node.devices_config
device_class = None
# 从配置中获取设备类型
for tree in devices_config.trees:
node = tree.root_node
if node.res_content.id == device_id:
device_class = node.res_content.klass
break
if device_class and device_class in lab_registry.device_type_registry:
device_type_info = lab_registry.device_type_registry[device_class]
class_info = device_type_info.get("class", {})
action_mappings = class_info.get("action_value_mappings", {})
# 尝试直接匹配或 auto- 前缀匹配
for key in [action_name, f"auto-{action_name}"]:
if key in action_mappings:
mapping = action_mappings[key]
result["schema"] = mapping.get("schema")
result["goal_default"] = mapping.get("goal_default")
result["action_type"] = str(mapping.get("type", ""))
result["handles"] = mapping.get("handles", {})
result["placeholder_keys"] = mapping.get("placeholder_keys", {})
break
if result["schema"] is None:
return False, {"error": f"Action schema not found: {device_id}/{action_name}"}
return True, result
except Exception as e:
logger.error(f"[Controller] Error getting action schema: {str(e)}")
traceback.print_exc()
return False, {"error": str(e)}
def get_all_available_actions() -> Tuple[bool, Dict[str, Any]]:
"""获取所有设备的可用动作
Returns:
Tuple[bool, Dict]: (是否成功, 所有设备的动作信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, {"error": "Host node not initialized"}
try:
from unilabos.ros.nodes.base_device_node import registered_devices
from unilabos.app.web.utils.action_utils import get_action_info
all_actions = {}
total_action_count = 0
for device_id, device_info in registered_devices.items():
actions = device_info.get("actions", {})
device_actions = {}
for action_name, action_server in actions.items():
try:
action_info = get_action_info(action_server, action_name)
is_busy, current_job = check_device_action_busy(device_id, action_name)
device_actions[action_name] = {
"type_name": action_info.get("type_name", ""),
"action_path": action_info.get("action_path", ""),
"is_busy": is_busy,
"current_job_id": current_job[:8] if current_job else None,
}
total_action_count += 1
except Exception as e:
logger.warning(f"[Controller] Error processing action {device_id}/{action_name}: {str(e)}")
if device_actions:
all_actions[device_id] = {
"actions": device_actions,
"action_count": len(device_actions),
"machine_name": host_node.device_machine_names.get(device_id, "未知"),
}
return True, {
"devices": all_actions,
"device_count": len(all_actions),
"total_action_count": total_action_count,
"timestamp": time.time(),
}
except Exception as e:
logger.error(f"[Controller] Error getting all available actions: {str(e)}")
traceback.print_exc()
return False, {"error": str(e)}

View File

@@ -359,6 +359,7 @@ class MessageProcessor:
self.device_manager = device_manager
self.queue_processor = None # 延迟设置
self.websocket_client = None # 延迟设置
self.session_id = ""
# WebSocket连接
self.websocket = None
@@ -388,7 +389,7 @@ class MessageProcessor:
self.is_running = True
self.thread = threading.Thread(target=self._run, daemon=True, name="MessageProcessor")
self.thread.start()
logger.info("[MessageProcessor] Started")
logger.trace("[MessageProcessor] Started")
def stop(self) -> None:
"""停止消息处理线程"""
@@ -427,7 +428,10 @@ class MessageProcessor:
ssl=ssl_context,
ping_interval=WSConfig.ping_interval,
ping_timeout=10,
additional_headers={"Authorization": f"Lab {BasicConfig.auth_secret()}"},
additional_headers={
"Authorization": f"Lab {BasicConfig.auth_secret()}",
"EdgeSession": f"{self.session_id}",
},
logger=ws_logger,
) as websocket:
self.websocket = websocket
@@ -572,6 +576,9 @@ class MessageProcessor:
await self._handle_resource_tree_update(message_data, "update")
elif message_type == "remove_material":
await self._handle_resource_tree_update(message_data, "remove")
elif message_type == "session_id":
self.session_id = message_data.get("session_id")
logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
else:
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
@@ -932,7 +939,7 @@ class QueueProcessor:
# 事件通知机制
self.queue_update_event = threading.Event()
logger.info("[QueueProcessor] Initialized")
logger.trace("[QueueProcessor] Initialized")
def set_websocket_client(self, websocket_client: "WebSocketClient"):
"""设置WebSocket客户端引用"""
@@ -947,7 +954,7 @@ class QueueProcessor:
self.is_running = True
self.thread = threading.Thread(target=self._run, daemon=True, name="QueueProcessor")
self.thread.start()
logger.info("[QueueProcessor] Started")
logger.trace("[QueueProcessor] Started")
def stop(self) -> None:
"""停止队列处理线程"""
@@ -1196,6 +1203,18 @@ class WebSocketClient(BaseCommunicationClient):
logger.info("[WebSocketClient] Stopping connection")
# 发送 normal_exit 消息
if self.is_connected():
try:
session_id = self.message_processor.session_id
message = {"action": "normal_exit", "data": {"session_id": session_id}}
self.message_processor.send_message(message)
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
# 给一点时间让消息发送出去
time.sleep(1)
except Exception as e:
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
# 停止两个核心线程
self.message_processor.stop()
self.queue_processor.stop()
@@ -1295,3 +1314,19 @@ class WebSocketClient(BaseCommunicationClient):
logger.info(f"[WebSocketClient] Job {job_log} cancelled successfully")
else:
logger.warning(f"[WebSocketClient] Failed to cancel job {job_log}")
def publish_host_ready(self) -> None:
"""发布host_node ready信号"""
if self.is_disabled or not self.is_connected():
logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal")
return
message = {
"action": "host_node_ready",
"data": {
"status": "ready",
"timestamp": time.time(),
},
}
self.message_processor.send_message(message)
logger.info("[WebSocketClient] Host node ready signal published")

View File

@@ -18,6 +18,9 @@ class BasicConfig:
vis_2d_enable = False
enable_resource_load = True
communication_protocol = "websocket"
startup_json_path = None # 填写绝对路径
disable_browser = False # 禁止浏览器自动打开
port = 8002 # 本地HTTP服务
log_level: Literal['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = "DEBUG" # 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
@classmethod
@@ -36,18 +39,9 @@ class WSConfig:
ping_interval = 30 # ping间隔
# OSS上传配置
class OSSUploadConfig:
api_host = ""
authorization = ""
init_endpoint = ""
complete_endpoint = ""
max_retries = 3
# HTTP配置
class HTTPConfig:
remote_addr = "http://127.0.0.1:48197/api/v1"
remote_addr = "https://uni-lab.bohrium.com/api/v1"
# ROS配置

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ from enum import Enum
from abc import ABC, abstractmethod
from typing import Tuple, Union, Optional, Any, List
from opcua import Client, Node
from opcua import Client, Node, ua
from opcua.ua import NodeId, NodeClass, VariantType
@@ -47,23 +47,68 @@ class Base(ABC):
def _get_node(self) -> Node:
if self._node is None:
try:
# 检查是否是NumericNodeId(ns=X;i=Y)格式
if "NumericNodeId" in self._node_id:
# 从字符串中提取命名空间和标识符
import re
match = re.search(r'ns=(\d+);i=(\d+)', self._node_id)
if match:
ns = int(match.group(1))
identifier = int(match.group(2))
node_id = NodeId(identifier, ns)
self._node = self._client.get_node(node_id)
# 尝试多种 NodeId 字符串格式解析,兼容不同服务器/库的输出
# 可能的格式示例: 'ns=2;i=1234', 'ns=2;s=SomeString',
# 'StringNodeId(ns=4;s=OPC|变量名)', 'NumericNodeId(ns=2;i=1234)' 等
import re
nid = self._node_id
# 如果已经是 NodeId/Node 对象(库用户可能传入),直接使用
try:
from opcua.ua import NodeId as UaNodeId
if isinstance(nid, UaNodeId):
self._node = self._client.get_node(nid)
return self._node
except Exception:
# 若导入或类型判断失败,则继续下一步
pass
# 直接以字符串形式处理
if isinstance(nid, str):
nid = nid.strip()
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
# 提取括号内的内容
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
if match_wrapped:
# 提取括号内的实际 node_id 字符串
nid = match_wrapped.group(2).strip()
# 常见短格式 'ns=2;i=1234' 或 'ns=2;s=SomeString'
if re.match(r'^ns=\d+;[is]=', nid):
self._node = self._client.get_node(nid)
else:
raise ValueError(f"无法解析节点ID: {self._node_id}")
# 尝试提取 ns 和 i 或 s
# 对于字符串标识符,可能包含特殊字符,使用非贪婪匹配
m_num = re.search(r'ns=(\d+);i=(\d+)', nid)
m_str = re.search(r'ns=(\d+);s=(.+?)(?:\)|$)', nid)
if m_num:
ns = int(m_num.group(1))
identifier = int(m_num.group(2))
node_id = NodeId(identifier, ns)
self._node = self._client.get_node(node_id)
elif m_str:
ns = int(m_str.group(1))
identifier = m_str.group(2).strip()
# 对于字符串标识符,直接使用字符串格式
node_id_str = f"ns={ns};s={identifier}"
self._node = self._client.get_node(node_id_str)
else:
# 回退:尝试直接传入字符串(有些实现接受其它格式)
try:
self._node = self._client.get_node(self._node_id)
except Exception as e:
# 输出更详细的错误信息供调试
print(f"获取节点失败(尝试直接字符串): {self._node_id}, 错误: {e}")
raise
else:
# 直接使用节点ID字符串
# 非字符串,尝试直接使用
self._node = self._client.get_node(self._node_id)
except Exception as e:
print(f"获取节点失败: {self._node_id}, 错误: {e}")
# 添加额外提示,帮助定位 BadNodeIdUnknown 问题
print("提示: 请确认该 node_id 是否来自当前连接的服务器地址空间," \
"以及 CSV/配置中名称与服务器 BrowseName 是否匹配。")
raise
return self._node
@@ -104,7 +149,56 @@ class Variable(Base):
def write(self, value: Any) -> bool:
try:
self._get_node().set_value(value)
# 如果声明了数据类型,则尝试转换并使用对应的 Variant 写入
coerced = value
try:
if self._data_type is not None:
# 基于声明的数据类型做简单类型转换
dt = self._data_type
if dt in (DataType.SBYTE, DataType.BYTE, DataType.INT16, DataType.UINT16,
DataType.INT32, DataType.UINT32, DataType.INT64, DataType.UINT64):
# 数值类型 -> int
if isinstance(value, str):
coerced = int(value)
else:
coerced = int(value)
elif dt in (DataType.FLOAT, DataType.DOUBLE):
if isinstance(value, str):
coerced = float(value)
else:
coerced = float(value)
elif dt == DataType.BOOLEAN:
if isinstance(value, str):
v = value.strip().lower()
if v in ("true", "1", "yes", "on"):
coerced = True
elif v in ("false", "0", "no", "off"):
coerced = False
else:
coerced = bool(value)
else:
coerced = bool(value)
elif dt == DataType.STRING or dt == DataType.BYTESTRING or dt == DataType.DATETIME:
coerced = str(value)
# 使用 ua.Variant 明确指定 VariantType
try:
variant = ua.Variant(coerced, dt.value)
self._get_node().set_value(variant)
except Exception:
# 回退:有些 set_value 实现接受 (value, variant_type)
try:
self._get_node().set_value(coerced, dt.value)
except Exception:
# 最后回退到直接写入(保持兼容性)
self._get_node().set_value(coerced)
else:
# 未声明数据类型,直接写入
self._get_node().set_value(value)
except Exception:
# 若在转换或按数据类型写入失败,尝试直接写入原始值并让上层捕获错误
self._get_node().set_value(value)
return False
except Exception as e:
print(f"写入变量 {self._name} 失败: {e}")
@@ -120,20 +214,50 @@ class Method(Base):
def _get_parent_node(self) -> Node:
if self._parent_node is None:
try:
# 检查是否是NumericNodeId(ns=X;i=Y)格式
if "NumericNodeId" in self._parent_node_id:
# 从字符串中提取命名空间和标识符
import re
match = re.search(r'ns=(\d+);i=(\d+)', self._parent_node_id)
if match:
ns = int(match.group(1))
identifier = int(match.group(2))
node_id = NodeId(identifier, ns)
self._parent_node = self._client.get_node(node_id)
# 处理父节点ID使用与_get_node相同的解析逻辑
import re
nid = self._parent_node_id
# 如果已经是 NodeId 对象,直接使用
try:
from opcua.ua import NodeId as UaNodeId
if isinstance(nid, UaNodeId):
self._parent_node = self._client.get_node(nid)
return self._parent_node
except Exception:
pass
# 字符串处理
if isinstance(nid, str):
nid = nid.strip()
# 处理包含类名的格式
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
if match_wrapped:
nid = match_wrapped.group(2).strip()
# 常见短格式
if re.match(r'^ns=\d+;[is]=', nid):
self._parent_node = self._client.get_node(nid)
else:
raise ValueError(f"无法解析父节点ID: {self._parent_node_id}")
# 提取 ns 和 i 或 s
m_num = re.search(r'ns=(\d+);i=(\d+)', nid)
m_str = re.search(r'ns=(\d+);s=(.+?)(?:\)|$)', nid)
if m_num:
ns = int(m_num.group(1))
identifier = int(m_num.group(2))
node_id = NodeId(identifier, ns)
self._parent_node = self._client.get_node(node_id)
elif m_str:
ns = int(m_str.group(1))
identifier = m_str.group(2).strip()
node_id_str = f"ns={ns};s={identifier}"
self._parent_node = self._client.get_node(node_id_str)
else:
# 回退
self._parent_node = self._client.get_node(self._parent_node_id)
else:
# 直接使用节点ID字符串
self._parent_node = self._client.get_node(self._parent_node_id)
except Exception as e:
print(f"获取父节点失败: {self._parent_node_id}, 错误: {e}")

View File

@@ -128,14 +128,21 @@ class ResourceVisualization:
new_dev.set("device_name", node["id"]+"_")
# if node["parent"] is not None:
# new_dev.set("station_name", node["parent"]+'_')
new_dev.set("x",str(float(node["position"]["position"]["x"])/1000))
new_dev.set("y",str(float(node["position"]["position"]["y"])/1000))
new_dev.set("z",str(float(node["position"]["position"]["z"])/1000))
if "position" in node:
new_dev.set("x",str(float(node["position"]["position"]["x"])/1000))
new_dev.set("y",str(float(node["position"]["position"]["y"])/1000))
new_dev.set("z",str(float(node["position"]["position"]["z"])/1000))
if "rotation" in node["config"]:
new_dev.set("rx",str(float(node["config"]["rotation"]["x"])))
new_dev.set("ry",str(float(node["config"]["rotation"]["y"])))
new_dev.set("r",str(float(node["config"]["rotation"]["z"])))
if "pose" in node:
new_dev.set("x",str(float(node["pose"]["position"]["x"])/1000))
new_dev.set("y",str(float(node["pose"]["position"]["y"])/1000))
new_dev.set("z",str(float(node["pose"]["position"]["z"])/1000))
new_dev.set("rx",str(float(node["pose"]["rotation"]["x"])))
new_dev.set("ry",str(float(node["pose"]["rotation"]["y"])))
new_dev.set("r",str(float(node["pose"]["rotation"]["z"])))
if "device_config" in node["config"]:
for key, value in node["config"]["device_config"].items():
new_dev.set(key, str(value))

View File

View File

@@ -0,0 +1,712 @@
#!/usr/bin/env python3
import asyncio
import json
import subprocess
import sys
import threading
from typing import Optional, Dict, Any
import logging
import requests
import websockets
logging.getLogger("zeep").setLevel(logging.WARNING)
logging.getLogger("zeep.xsd.schema").setLevel(logging.WARNING)
logging.getLogger("zeep.xsd.schema.schema").setLevel(logging.WARNING)
from onvif import ONVIFCamera # 新增ONVIF PTZ 控制
# ======================= 独立的 PTZController =======================
class PTZController:
def __init__(self, host: str, port: int, user: str, password: str):
"""
:param host: 摄像机 IP 或域名(和 RTSP 的一样即可)
:param port: ONVIF 端口(多数为 80看你的设备
:param user: 摄像机用户名
:param password: 摄像机密码
"""
self.host = host
self.port = port
self.user = user
self.password = password
self.cam: Optional[ONVIFCamera] = None
self.media_service = None
self.ptz_service = None
self.profile = None
def connect(self) -> bool:
"""
建立 ONVIF 连接并初始化 PTZ 能力,失败返回 False不抛异常
Note: 首先 pip install onvif-zeep
"""
try:
self.cam = ONVIFCamera(self.host, self.port, self.user, self.password)
self.media_service = self.cam.create_media_service()
self.ptz_service = self.cam.create_ptz_service()
profiles = self.media_service.GetProfiles()
if not profiles:
print("[PTZ] No media profiles found on camera.", file=sys.stderr)
return False
self.profile = profiles[0]
return True
except Exception as e:
print(f"[PTZ] Failed to init ONVIF PTZ: {e}", file=sys.stderr)
return False
def _continuous_move(self, pan: float, tilt: float, zoom: float, duration: float) -> bool:
"""
连续移动一段时间(秒),之后自动停止。
此函数为阻塞模式:只有在 Stop 调用结束后,才返回 True/False。
"""
if not self.ptz_service or not self.profile:
print("[PTZ] _continuous_move: ptz_service or profile not ready", file=sys.stderr)
return False
# 进入前先强行停一下,避免前一次残留动作
self._force_stop()
req = self.ptz_service.create_type("ContinuousMove")
req.ProfileToken = self.profile.token
req.Velocity = {
"PanTilt": {"x": pan, "y": tilt},
"Zoom": {"x": zoom},
}
try:
print(f"[PTZ] ContinuousMove start: pan={pan}, tilt={tilt}, zoom={zoom}, duration={duration}", file=sys.stderr)
self.ptz_service.ContinuousMove(req)
except Exception as e:
print(f"[PTZ] ContinuousMove failed: {e}", file=sys.stderr)
return False
# 阻塞等待:这里决定“运动时间”
import time
wait_seconds = max(2 * duration, 0.0)
time.sleep(wait_seconds)
# 运动完成后强制停止
return self._force_stop()
def stop(self) -> bool:
"""
阻塞调用 Stop带重试成功 True失败 False。
"""
return self._force_stop()
# ------- 对外动作接口(给 CameraController 调用) -------
# 所有接口都为“阻塞模式”:只有在运动 + Stop 完成后才返回 True/False
def move_up(self, speed: float = 0.5, duration: float = 1.0) -> bool:
print(f"[PTZ] move_up called, speed={speed}, duration={duration}", file=sys.stderr)
return self._continuous_move(pan=0.0, tilt=+speed, zoom=0.0, duration=duration)
def move_down(self, speed: float = 0.5, duration: float = 1.0) -> bool:
print(f"[PTZ] move_down called, speed={speed}, duration={duration}", file=sys.stderr)
return self._continuous_move(pan=0.0, tilt=-speed, zoom=0.0, duration=duration)
def move_left(self, speed: float = 0.2, duration: float = 1.0) -> bool:
print(f"[PTZ] move_left called, speed={speed}, duration={duration}", file=sys.stderr)
return self._continuous_move(pan=-speed, tilt=0.0, zoom=0.0, duration=duration)
def move_right(self, speed: float = 0.2, duration: float = 1.0) -> bool:
print(f"[PTZ] move_right called, speed={speed}, duration={duration}", file=sys.stderr)
return self._continuous_move(pan=+speed, tilt=0.0, zoom=0.0, duration=duration)
# ------- 占位的变倍接口(当前设备不支持) -------
def zoom_in(self, speed: float = 0.2, duration: float = 1.0) -> bool:
"""
当前设备不支持变倍;保留方法只是避免上层调用时报错。
"""
print("[PTZ] zoom_in is disabled for this device.", file=sys.stderr)
return False
def zoom_out(self, speed: float = 0.2, duration: float = 1.0) -> bool:
"""
当前设备不支持变倍;保留方法只是避免上层调用时报错。
"""
print("[PTZ] zoom_out is disabled for this device.", file=sys.stderr)
return False
def _force_stop(self, retries: int = 3, delay: float = 0.1) -> bool:
"""
尝试多次调用 Stop作为“强制停止”手段。
:param retries: 重试次数
:param delay: 每次重试间隔(秒)
"""
if not self.ptz_service or not self.profile:
print("[PTZ] _force_stop: ptz_service or profile not ready", file=sys.stderr)
return False
import time
last_error = None
for i in range(retries):
try:
print(f"[PTZ] _force_stop: calling Stop(), attempt={i+1}", file=sys.stderr)
self.ptz_service.Stop({"ProfileToken": self.profile.token})
print("[PTZ] _force_stop: Stop() returned OK", file=sys.stderr)
return True
except Exception as e:
last_error = e
print(f"[PTZ] _force_stop: Stop() failed at attempt {i+1}: {e}", file=sys.stderr)
time.sleep(delay)
print(f"[PTZ] _force_stop: all {retries} attempts failed, last error: {last_error}", file=sys.stderr)
return False
# ======================= CameraController加入 PTZ =======================
class CameraController:
"""
Uni-Lab-OS 摄像头驱动driver 形式)
启动 Uni-Lab-OS 后,立即开始推流
- WebSocket 信令:通过 signal_backend_url 连接到后端
例如: wss://sciol.ac.cn/api/realtime/signal/host/<host_id>
- 媒体服务器:通过 rtmp_url / webrtc_api / webrtc_stream_url
当前配置为 SRS与独立 HostSimulator 独立运行脚本保持一致。
"""
def __init__(
self,
host_id: str = "demo-host",
# 1信令后端WebSocket
signal_backend_url: str = "wss://sciol.ac.cn/api/realtime/signal/host",
# 2媒体后端RTMP + WebRTC API
rtmp_url: str = "rtmp://srs.sciol.ac.cn:4499/live/camera-01",
webrtc_api: str = "https://srs.sciol.ac.cn/rtc/v1/play/",
webrtc_stream_url: str = "webrtc://srs.sciol.ac.cn:4500/live/camera-01",
camera_rtsp_url: str = "",
# 3PTZ 控制相关ONVIF
ptz_host: str = "", # 一般就是摄像头 IP比如 "192.168.31.164"
ptz_port: int = 80, # ONVIF 端口,不一定是 80按实际情况改
ptz_user: str = "", # admin
ptz_password: str = "", # admin123
):
self.host_id = host_id
self.camera_rtsp_url = camera_rtsp_url
# 拼接最终的 WebSocket URL.../host/<host_id>
signal_backend_url = signal_backend_url.rstrip("/")
if not signal_backend_url.endswith("/host"):
signal_backend_url = signal_backend_url + "/host"
self.signal_backend_url = f"{signal_backend_url}/{host_id}"
# 媒体服务器配置
self.rtmp_url = rtmp_url
self.webrtc_api = webrtc_api
self.webrtc_stream_url = webrtc_stream_url
# PTZ 控制
self.ptz_host = ptz_host
self.ptz_port = ptz_port
self.ptz_user = ptz_user
self.ptz_password = ptz_password
self._ptz: Optional[PTZController] = None
self._init_ptz_if_possible()
# 运行时状态
self._ws: Optional[object] = None
self._ffmpeg_process: Optional[subprocess.Popen] = None
self._running = False
self._loop_task: Optional[asyncio.Future] = None
# 事件循环 & 线程
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._loop_thread: Optional[threading.Thread] = None
try:
self.start()
except Exception as e:
print(f"[CameraController] __init__ auto start failed: {e}", file=sys.stderr)
# ------------------------ PTZ 初始化 ------------------------
# ------------------------ PTZ 公开动作方法(一个动作一个函数) ------------------------
def ptz_move_up(self, speed: float = 0.5, duration: float = 1.0) -> bool:
print(f"[CameraController] ptz_move_up called, speed={speed}, duration={duration}")
return self._ptz.move_up(speed=speed, duration=duration)
def ptz_move_down(self, speed: float = 0.5, duration: float = 1.0) -> bool:
print(f"[CameraController] ptz_move_down called, speed={speed}, duration={duration}")
return self._ptz.move_down(speed=speed, duration=duration)
def ptz_move_left(self, speed: float = 0.2, duration: float = 1.0) -> bool:
print(f"[CameraController] ptz_move_left called, speed={speed}, duration={duration}")
return self._ptz.move_left(speed=speed, duration=duration)
def ptz_move_right(self, speed: float = 0.2, duration: float = 1.0) -> bool:
print(f"[CameraController] ptz_move_right called, speed={speed}, duration={duration}")
return self._ptz.move_right(speed=speed, duration=duration)
def zoom_in(self, speed: float = 0.2, duration: float = 1.0) -> bool:
"""
当前设备不支持变倍;保留方法只是避免上层调用时报错。
"""
print("[PTZ] zoom_in is disabled for this device.", file=sys.stderr)
return False
def zoom_out(self, speed: float = 0.2, duration: float = 1.0) -> bool:
"""
当前设备不支持变倍;保留方法只是避免上层调用时报错。
"""
print("[PTZ] zoom_out is disabled for this device.", file=sys.stderr)
return False
def ptz_stop(self):
if self._ptz is None:
print("[CameraController] PTZ not initialized.", file=sys.stderr)
return
self._ptz.stop()
def _init_ptz_if_possible(self):
"""
根据 ptz_host / user / password 初始化 PTZ
如果配置信息不全则不启用 PTZ静默
"""
if not (self.ptz_host and self.ptz_user and self.ptz_password):
return
ctrl = PTZController(
host=self.ptz_host,
port=self.ptz_port,
user=self.ptz_user,
password=self.ptz_password,
)
if ctrl.connect():
self._ptz = ctrl
else:
self._ptz = None
# ---------------------------------------------------------------------
# 对外暴露的方法:供 Uni-Lab-OS 调用
# ---------------------------------------------------------------------
def start(self, config: Optional[Dict[str, Any]] = None):
"""
启动 Camera 连接 & 消息循环,并在启动时就开启 FFmpeg 推流,
"""
if self._running:
return {"status": "already_running", "host_id": self.host_id}
# 应用 config 覆盖(如果有)
if config:
self.camera_rtsp_url = config.get("camera_rtsp_url", self.camera_rtsp_url)
cfg_host_id = config.get("host_id")
if cfg_host_id:
self.host_id = cfg_host_id
signal_backend_url = config.get("signal_backend_url")
if signal_backend_url:
signal_backend_url = signal_backend_url.rstrip("/")
if not signal_backend_url.endswith("/host"):
signal_backend_url = signal_backend_url + "/host"
self.signal_backend_url = f"{signal_backend_url}/{self.host_id}"
self.rtmp_url = config.get("rtmp_url", self.rtmp_url)
self.webrtc_api = config.get("webrtc_api", self.webrtc_api)
self.webrtc_stream_url = config.get(
"webrtc_stream_url", self.webrtc_stream_url
)
# PTZ 相关配置也允许通过 config 注入
self.ptz_host = config.get("ptz_host", self.ptz_host)
self.ptz_port = int(config.get("ptz_port", self.ptz_port))
self.ptz_user = config.get("ptz_user", self.ptz_user)
self.ptz_password = config.get("ptz_password", self.ptz_password)
self._init_ptz_if_possible()
self._running = True
# === start 时启动 FFmpeg 推流 ===
self._start_ffmpeg()
# 创建新的事件循环和线程(用于 WebSocket 信令)
self._loop = asyncio.new_event_loop()
def loop_runner(loop: asyncio.AbstractEventLoop):
asyncio.set_event_loop(loop)
try:
loop.run_forever()
except Exception as e:
print(f"[CameraController] event loop error: {e}", file=sys.stderr)
self._loop_thread = threading.Thread(
target=loop_runner, args=(self._loop,), daemon=True
)
self._loop_thread.start()
self._loop_task = asyncio.run_coroutine_threadsafe(
self._run_main_loop(), self._loop
)
return {
"status": "started",
"host_id": self.host_id,
"signal_backend_url": self.signal_backend_url,
"rtmp_url": self.rtmp_url,
"webrtc_api": self.webrtc_api,
"webrtc_stream_url": self.webrtc_stream_url,
}
def stop(self) -> Dict[str, Any]:
"""
停止推流 & 断开 WebSocket并关闭事件循环线程。
"""
self._running = False
self._stop_ffmpeg()
if self._ws and self._loop is not None:
async def close_ws():
try:
await self._ws.close()
except Exception as e:
print(
f"[CameraController] error when closing WebSocket: {e}",
file=sys.stderr,
)
asyncio.run_coroutine_threadsafe(close_ws(), self._loop)
if self._loop_task is not None:
if not self._loop_task.done():
self._loop_task.cancel()
try:
self._loop_task.result()
except asyncio.CancelledError:
pass
except Exception as e:
print(
f"[CameraController] main loop task error in stop(): {e}",
file=sys.stderr,
)
finally:
self._loop_task = None
if self._loop is not None:
try:
self._loop.call_soon_threadsafe(self._loop.stop)
except Exception as e:
print(
f"[CameraController] error when stopping event loop: {e}",
file=sys.stderr,
)
if self._loop_thread is not None:
try:
self._loop_thread.join(timeout=5)
except Exception as e:
print(
f"[CameraController] error when joining loop thread: {e}",
file=sys.stderr,
)
finally:
self._loop_thread = None
self._ws = None
self._loop = None
return {"status": "stopped", "host_id": self.host_id}
def get_status(self) -> Dict[str, Any]:
"""
查询当前状态,方便在 Uni-Lab-OS 中做监控。
"""
ws_closed = None
if self._ws is not None:
ws_closed = getattr(self._ws, "closed", None)
if ws_closed is None:
websocket_connected = self._ws is not None
else:
websocket_connected = (self._ws is not None) and (not ws_closed)
return {
"host_id": self.host_id,
"running": self._running,
"websocket_connected": websocket_connected,
"ffmpeg_running": bool(
self._ffmpeg_process and self._ffmpeg_process.poll() is None
),
"signal_backend_url": self.signal_backend_url,
"rtmp_url": self.rtmp_url,
}
# ---------------------------------------------------------------------
# 内部实现逻辑WebSocket 循环 / FFmpeg / WebRTC Offer 处理
# ---------------------------------------------------------------------
async def _run_main_loop(self):
try:
while self._running:
try:
async with websockets.connect(self.signal_backend_url) as ws:
self._ws = ws
await self._recv_loop()
except asyncio.CancelledError:
raise
except Exception as e:
if self._running:
print(
f"[CameraController] WebSocket connection error: {e}",
file=sys.stderr,
)
await asyncio.sleep(3)
except asyncio.CancelledError:
pass
async def _recv_loop(self):
assert self._ws is not None
ws = self._ws
async for message in ws:
try:
data = json.loads(message)
except json.JSONDecodeError:
print(
f"[CameraController] received non-JSON message: {message}",
file=sys.stderr,
)
continue
try:
await self._handle_message(data)
except Exception as e:
print(
f"[CameraController] error while handling message {data}: {e}",
file=sys.stderr,
)
async def _handle_message(self, data: Dict[str, Any]):
"""
处理来自信令后端的消息:
- command: start_stream / stop_stream / ptz_xxx
- type: offer (WebRTC)
"""
cmd = data.get("command")
# ---------- 推流控制 ----------
if cmd == "start_stream":
try:
self._start_ffmpeg()
except Exception as e:
print(
f"[CameraController] error when starting FFmpeg on start_stream: {e}",
file=sys.stderr,
)
return
if cmd == "stop_stream":
try:
self._stop_ffmpeg()
except Exception as e:
print(
f"[CameraController] error when stopping FFmpeg on stop_stream: {e}",
file=sys.stderr,
)
return
# # ---------- PTZ 控制 ----------
# # 例如信令可以发:
# # {"command": "ptz_move", "direction": "down", "speed": 0.5, "duration": 0.5}
# if cmd == "ptz_move":
# if self._ptz is None:
# # 没有初始化 PTZ静默忽略或打印一条
# print("[CameraController] PTZ not initialized.", file=sys.stderr)
# return
# direction = data.get("direction", "")
# speed = float(data.get("speed", 0.5))
# duration = float(data.get("duration", 0.5))
# try:
# if direction == "up":
# self._ptz.move_up(speed=speed, duration=duration)
# elif direction == "down":
# self._ptz.move_down(speed=speed, duration=duration)
# elif direction == "left":
# self._ptz.move_left(speed=speed, duration=duration)
# elif direction == "right":
# self._ptz.move_right(speed=speed, duration=duration)
# elif direction == "zoom_in":
# self._ptz.zoom_in(speed=speed, duration=duration)
# elif direction == "zoom_out":
# self._ptz.zoom_out(speed=speed, duration=duration)
# elif direction == "stop":
# self._ptz.stop()
# else:
# # 未知方向,忽略
# pass
# except Exception as e:
# print(
# f"[CameraController] error when handling PTZ move: {e}",
# file=sys.stderr,
# )
# return
# ---------- WebRTC Offer ----------
if data.get("type") == "offer":
offer_sdp = data.get("sdp", "")
camera_id = data.get("cameraId", "camera-01")
try:
answer_sdp = await self._handle_webrtc_offer(offer_sdp)
except Exception as e:
print(
f"[CameraController] error when handling WebRTC offer: {e}",
file=sys.stderr,
)
return
if self._ws:
answer_payload = {
"type": "answer",
"sdp": answer_sdp,
"cameraId": camera_id,
"hostId": self.host_id,
}
try:
await self._ws.send(json.dumps(answer_payload))
except Exception as e:
print(
f"[CameraController] error when sending WebRTC answer: {e}",
file=sys.stderr,
)
# ------------------------ FFmpeg 相关 ------------------------
def _start_ffmpeg(self):
if self._ffmpeg_process and self._ffmpeg_process.poll() is None:
return
cmd = [
"ffmpeg",
"-rtsp_transport", "tcp",
"-i", self.camera_rtsp_url,
"-c:v", "libx264",
"-preset", "ultrafast",
"-tune", "zerolatency",
"-profile:v", "baseline",
"-b:v", "1M",
"-maxrate", "1M",
"-bufsize", "2M",
"-g", "10",
"-keyint_min", "10",
"-sc_threshold", "0",
"-pix_fmt", "yuv420p",
"-x264-params", "bframes=0",
"-c:a", "aac",
"-ar", "44100",
"-ac", "1",
"-b:a", "64k",
"-f", "flv",
self.rtmp_url,
]
try:
self._ffmpeg_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT,
shell=False,
)
except Exception as e:
print(f"[CameraController] failed to start FFmpeg: {e}", file=sys.stderr)
self._ffmpeg_process = None
raise
def _stop_ffmpeg(self):
proc = self._ffmpeg_process
if proc and proc.poll() is None:
try:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
try:
proc.kill()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
print(
f"[CameraController] FFmpeg process did not exit even after kill (pid={proc.pid})",
file=sys.stderr,
)
except Exception as e:
print(
f"[CameraController] failed to kill FFmpeg process: {e}",
file=sys.stderr,
)
except Exception as e:
print(
f"[CameraController] error when stopping FFmpeg: {e}",
file=sys.stderr,
)
self._ffmpeg_process = None
# ------------------------ WebRTC Offer 相关 ------------------------
async def _handle_webrtc_offer(self, offer_sdp: str) -> str:
payload = {
"api": self.webrtc_api,
"streamurl": self.webrtc_stream_url,
"sdp": offer_sdp,
}
headers = {"Content-Type": "application/json"}
def _do_request():
return requests.post(
self.webrtc_api,
json=payload,
headers=headers,
timeout=10,
)
try:
loop = asyncio.get_running_loop()
resp = await loop.run_in_executor(None, _do_request)
except Exception as e:
print(
f"[CameraController] failed to send offer to media server: {e}",
file=sys.stderr,
)
raise
try:
resp.raise_for_status()
except Exception as e:
print(
f"[CameraController] media server HTTP error: {e}, "
f"status={resp.status_code}, body={resp.text[:200]}",
file=sys.stderr,
)
raise
try:
data = resp.json()
except Exception as e:
print(
f"[CameraController] failed to parse media server JSON: {e}, "
f"raw={resp.text[:200]}",
file=sys.stderr,
)
raise
answer_sdp = data.get("sdp", "")
if not answer_sdp:
msg = f"empty SDP from media server: {data}"
print(f"[CameraController] {msg}", file=sys.stderr)
raise RuntimeError(msg)
return answer_sdp

View File

@@ -0,0 +1,401 @@
#!/usr/bin/env python3
import asyncio
import json
import subprocess
import sys
import threading
from typing import Optional, Dict, Any
import requests
import websockets
class CameraController:
"""
Uni-Lab-OS 摄像头驱动Linux USB 摄像头版,无 PTZ
- WebSocket 信令signal_backend_url 连接到后端
例如: wss://sciol.ac.cn/api/realtime/signal/host/<host_id>
- 媒体服务器RTMP 推流到 rtmp_urlWebRTC offer 转发到 SRS 的 webrtc_api
- 视频源:本地 USB 摄像头V4L2默认 /dev/video0
"""
def __init__(
self,
host_id: str = "demo-host",
signal_backend_url: str = "wss://sciol.ac.cn/api/realtime/signal/host",
rtmp_url: str = "rtmp://srs.sciol.ac.cn:4499/live/camera-01",
webrtc_api: str = "https://srs.sciol.ac.cn/rtc/v1/play/",
webrtc_stream_url: str = "webrtc://srs.sciol.ac.cn:4500/live/camera-01",
video_device: str = "/dev/video0",
width: int = 1280,
height: int = 720,
fps: int = 30,
video_bitrate: str = "1500k",
audio_device: Optional[str] = None, # 比如 "hw:1,0",没有音频就保持 None
audio_bitrate: str = "64k",
):
self.host_id = host_id
# 拼接最终 WebSocket URL.../host/<host_id>
signal_backend_url = signal_backend_url.rstrip("/")
if not signal_backend_url.endswith("/host"):
signal_backend_url = signal_backend_url + "/host"
self.signal_backend_url = f"{signal_backend_url}/{host_id}"
# 媒体服务器配置
self.rtmp_url = rtmp_url
self.webrtc_api = webrtc_api
self.webrtc_stream_url = webrtc_stream_url
# 本地采集配置
self.video_device = video_device
self.width = int(width)
self.height = int(height)
self.fps = int(fps)
self.video_bitrate = video_bitrate
self.audio_device = audio_device
self.audio_bitrate = audio_bitrate
# 运行时状态
self._ws: Optional[object] = None
self._ffmpeg_process: Optional[subprocess.Popen] = None
self._running = False
self._loop_task: Optional[asyncio.Future] = None
# 事件循环 & 线程
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._loop_thread: Optional[threading.Thread] = None
try:
self.start()
except Exception as e:
print(f"[CameraController] __init__ auto start failed: {e}", file=sys.stderr)
# ---------------------------------------------------------------------
# 对外方法
# ---------------------------------------------------------------------
def start(self, config: Optional[Dict[str, Any]] = None):
if self._running:
return {"status": "already_running", "host_id": self.host_id}
# 应用 config 覆盖(如果有)
if config:
cfg_host_id = config.get("host_id")
if cfg_host_id:
self.host_id = cfg_host_id
signal_backend_url = config.get("signal_backend_url")
if signal_backend_url:
signal_backend_url = signal_backend_url.rstrip("/")
if not signal_backend_url.endswith("/host"):
signal_backend_url = signal_backend_url + "/host"
self.signal_backend_url = f"{signal_backend_url}/{self.host_id}"
self.rtmp_url = config.get("rtmp_url", self.rtmp_url)
self.webrtc_api = config.get("webrtc_api", self.webrtc_api)
self.webrtc_stream_url = config.get("webrtc_stream_url", self.webrtc_stream_url)
self.video_device = config.get("video_device", self.video_device)
self.width = int(config.get("width", self.width))
self.height = int(config.get("height", self.height))
self.fps = int(config.get("fps", self.fps))
self.video_bitrate = config.get("video_bitrate", self.video_bitrate)
self.audio_device = config.get("audio_device", self.audio_device)
self.audio_bitrate = config.get("audio_bitrate", self.audio_bitrate)
self._running = True
print("[CameraController] start(): starting FFmpeg streaming...", file=sys.stderr)
self._start_ffmpeg()
self._loop = asyncio.new_event_loop()
def loop_runner(loop: asyncio.AbstractEventLoop):
asyncio.set_event_loop(loop)
try:
loop.run_forever()
except Exception as e:
print(f"[CameraController] event loop error: {e}", file=sys.stderr)
self._loop_thread = threading.Thread(target=loop_runner, args=(self._loop,), daemon=True)
self._loop_thread.start()
self._loop_task = asyncio.run_coroutine_threadsafe(self._run_main_loop(), self._loop)
return {
"status": "started",
"host_id": self.host_id,
"signal_backend_url": self.signal_backend_url,
"rtmp_url": self.rtmp_url,
"webrtc_api": self.webrtc_api,
"webrtc_stream_url": self.webrtc_stream_url,
"video_device": self.video_device,
"width": self.width,
"height": self.height,
"fps": self.fps,
"video_bitrate": self.video_bitrate,
"audio_device": self.audio_device,
}
def stop(self) -> Dict[str, Any]:
self._running = False
# 先取消主任务(让 ws connect/sleep 尽快退出)
if self._loop_task is not None and not self._loop_task.done():
self._loop_task.cancel()
# 停止推流
self._stop_ffmpeg()
# 关闭 WebSocket在 loop 中执行)
if self._ws and self._loop is not None:
async def close_ws():
try:
await self._ws.close()
except Exception as e:
print(f"[CameraController] error closing WebSocket: {e}", file=sys.stderr)
try:
asyncio.run_coroutine_threadsafe(close_ws(), self._loop)
except Exception:
pass
# 停止事件循环
if self._loop is not None:
try:
self._loop.call_soon_threadsafe(self._loop.stop)
except Exception as e:
print(f"[CameraController] error stopping loop: {e}", file=sys.stderr)
# 等待线程退出
if self._loop_thread is not None:
try:
self._loop_thread.join(timeout=5)
except Exception as e:
print(f"[CameraController] error joining loop thread: {e}", file=sys.stderr)
self._ws = None
self._loop_task = None
self._loop = None
self._loop_thread = None
return {"status": "stopped", "host_id": self.host_id}
def get_status(self) -> Dict[str, Any]:
ws_closed = None
if self._ws is not None:
ws_closed = getattr(self._ws, "closed", None)
if ws_closed is None:
websocket_connected = self._ws is not None
else:
websocket_connected = (self._ws is not None) and (not ws_closed)
return {
"host_id": self.host_id,
"running": self._running,
"websocket_connected": websocket_connected,
"ffmpeg_running": bool(self._ffmpeg_process and self._ffmpeg_process.poll() is None),
"signal_backend_url": self.signal_backend_url,
"rtmp_url": self.rtmp_url,
"video_device": self.video_device,
"width": self.width,
"height": self.height,
"fps": self.fps,
"video_bitrate": self.video_bitrate,
}
# ---------------------------------------------------------------------
# WebSocket / 信令
# ---------------------------------------------------------------------
async def _run_main_loop(self):
print("[CameraController] main loop started", file=sys.stderr)
try:
while self._running:
try:
async with websockets.connect(self.signal_backend_url) as ws:
self._ws = ws
print(f"[CameraController] WebSocket connected: {self.signal_backend_url}", file=sys.stderr)
await self._recv_loop()
except asyncio.CancelledError:
raise
except Exception as e:
if self._running:
print(f"[CameraController] WebSocket connection error: {e}", file=sys.stderr)
await asyncio.sleep(3)
except asyncio.CancelledError:
pass
finally:
print("[CameraController] main loop exited", file=sys.stderr)
async def _recv_loop(self):
assert self._ws is not None
ws = self._ws
async for message in ws:
try:
data = json.loads(message)
except json.JSONDecodeError:
print(f"[CameraController] non-JSON message: {message}", file=sys.stderr)
continue
try:
await self._handle_message(data)
except Exception as e:
print(f"[CameraController] error handling message {data}: {e}", file=sys.stderr)
async def _handle_message(self, data: Dict[str, Any]):
cmd = data.get("command")
if cmd == "start_stream":
self._start_ffmpeg()
return
if cmd == "stop_stream":
self._stop_ffmpeg()
return
if data.get("type") == "offer":
offer_sdp = data.get("sdp", "")
camera_id = data.get("cameraId", "camera-01")
answer_sdp = await self._handle_webrtc_offer(offer_sdp)
if self._ws:
answer_payload = {
"type": "answer",
"sdp": answer_sdp,
"cameraId": camera_id,
"hostId": self.host_id,
}
await self._ws.send(json.dumps(answer_payload))
# ---------------------------------------------------------------------
# FFmpeg 推流V4L2 USB 摄像头)
# ---------------------------------------------------------------------
def _start_ffmpeg(self):
if self._ffmpeg_process and self._ffmpeg_process.poll() is None:
return
# 兼容性优先:不强制输入像素格式;失败再通过外部调整 width/height/fps
video_size = f"{self.width}x{self.height}"
cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"warning",
# video input
"-f", "v4l2",
"-framerate", str(self.fps),
"-video_size", video_size,
"-i", self.video_device,
]
# optional audio input
if self.audio_device:
cmd += [
"-f", "alsa",
"-i", self.audio_device,
"-c:a", "aac",
"-b:a", self.audio_bitrate,
"-ar", "44100",
"-ac", "1",
]
else:
cmd += ["-an"]
# video encode + rtmp out
cmd += [
"-c:v", "libx264",
"-preset", "ultrafast",
"-tune", "zerolatency",
"-profile:v", "baseline",
"-pix_fmt", "yuv420p",
"-b:v", self.video_bitrate,
"-maxrate", self.video_bitrate,
"-bufsize", "2M",
"-g", str(max(self.fps, 10)),
"-keyint_min", str(max(self.fps, 10)),
"-sc_threshold", "0",
"-x264-params", "bframes=0",
"-f", "flv",
self.rtmp_url,
]
print(f"[CameraController] starting FFmpeg: {' '.join(cmd)}", file=sys.stderr)
try:
# 不再丢弃日志,至少能看到 ffmpeg 报错(调试很关键)
self._ffmpeg_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=sys.stderr,
shell=False,
)
except Exception as e:
self._ffmpeg_process = None
print(f"[CameraController] failed to start FFmpeg: {e}", file=sys.stderr)
def _stop_ffmpeg(self):
proc = self._ffmpeg_process
if proc and proc.poll() is None:
try:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
except Exception as e:
print(f"[CameraController] error stopping FFmpeg: {e}", file=sys.stderr)
self._ffmpeg_process = None
# ---------------------------------------------------------------------
# WebRTC offer -> SRS
# ---------------------------------------------------------------------
async def _handle_webrtc_offer(self, offer_sdp: str) -> str:
payload = {
"api": self.webrtc_api,
"streamurl": self.webrtc_stream_url,
"sdp": offer_sdp,
}
headers = {"Content-Type": "application/json"}
def _do_post():
return requests.post(self.webrtc_api, json=payload, headers=headers, timeout=10)
loop = asyncio.get_running_loop()
resp = await loop.run_in_executor(None, _do_post)
resp.raise_for_status()
data = resp.json()
answer_sdp = data.get("sdp", "")
if not answer_sdp:
raise RuntimeError(f"empty SDP from media server: {data}")
return answer_sdp
if __name__ == "__main__":
# 直接运行用于手动测试
c = CameraController(
host_id="demo-host",
video_device="/dev/video0",
width=1280,
height=720,
fps=30,
video_bitrate="1500k",
audio_device=None,
)
try:
while True:
asyncio.sleep(1)
except KeyboardInterrupt:
c.stop()

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
import time
import json
from cameraUSB import CameraController
def main():
# 按你的实际情况改
cfg = dict(
host_id="demo-host",
signal_backend_url="wss://sciol.ac.cn/api/realtime/signal/host",
rtmp_url="rtmp://srs.sciol.ac.cn:4499/live/camera-01",
webrtc_api="https://srs.sciol.ac.cn/rtc/v1/play/",
webrtc_stream_url="webrtc://srs.sciol.ac.cn:4500/live/camera-01",
video_device="/dev/video7",
width=1280,
height=720,
fps=30,
video_bitrate="1500k",
audio_device=None,
)
c = CameraController(**cfg)
# 可选:如果你不想依赖 __init__ 自动 start可以这样显式调用
# c = CameraController(host_id=cfg["host_id"])
# c.start(cfg)
run_seconds = 30 # 测试运行时长
t0 = time.time()
try:
while True:
st = c.get_status()
print(json.dumps(st, ensure_ascii=False, indent=2))
if time.time() - t0 >= run_seconds:
break
time.sleep(2)
except KeyboardInterrupt:
print("Interrupted, stopping...")
finally:
print("Stopping controller...")
c.stop()
print("Done.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,36 @@
import cv2
# 推荐把 @ 进行 URL 编码:@ -> %40
RTSP_URL = "rtsp://admin:admin123@192.168.31.164:554/stream1"
OUTPUT_IMAGE = "rtsp_test_frame.jpg"
def main():
print(f"尝试连接 RTSP 流: {RTSP_URL}")
cap = cv2.VideoCapture(RTSP_URL)
if not cap.isOpened():
print("错误:无法打开 RTSP 流,请检查:")
print(" 1. IP/端口是否正确")
print(" 2. 账号密码(尤其是 @ 是否已转成 %40是否正确")
print(" 3. 摄像头是否允许当前主机访问(同一网段、防火墙等)")
return
print("连接成功,开始读取一帧...")
ret, frame = cap.read()
if not ret or frame is None:
print("错误:已连接但未能读取到帧数据(可能是码流未开启或网络抖动)")
cap.release()
return
# 保存当前帧
success = cv2.imwrite(OUTPUT_IMAGE, frame)
cap.release()
if success:
print(f"成功截取一帧并保存为: {OUTPUT_IMAGE}")
else:
print("错误:写入图片失败,请检查磁盘权限/路径")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,21 @@
# run_camera_push.py
import time
from cameraDriver import CameraController # 这里根据你的文件名调整
if __name__ == "__main__":
controller = CameraController(
host_id="demo-host",
signal_backend_url="wss://sciol.ac.cn/api/realtime/signal/host",
rtmp_url="rtmp://srs.sciol.ac.cn:4499/live/camera-01",
webrtc_api="https://srs.sciol.ac.cn/rtc/v1/play/",
webrtc_stream_url="webrtc://srs.sciol.ac.cn:4500/live/camera-01",
camera_rtsp_url="rtsp://admin:admin123@192.168.31.164:554/stream1",
)
try:
while True:
status = controller.get_status()
print(status)
time.sleep(5)
except KeyboardInterrupt:
controller.stop()

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
使用 CameraController 来测试 PTZ
让摄像头按顺序向下、向上、向左、向右运动几次。
"""
import time
import sys
# 根据你的工程结构修改导入路径:
# 假设 CameraController 定义在 cameraController.py 里
from cameraDriver import CameraController
def main():
# === 根据你的实际情况填 IP、端口、账号密码 ===
ptz_host = "192.168.31.164"
ptz_port = 2020 # 注意要和你单独测试 PTZController 时保持一致
ptz_user = "admin"
ptz_password = "admin123"
# 1. 创建 CameraController 实例
cam = CameraController(
# 其他摄像机相关参数按你类的 __init__ 来补充
ptz_host=ptz_host,
ptz_port=ptz_port,
ptz_user=ptz_user,
ptz_password=ptz_password,
)
# 2. 启动 / 初始化(如果你的 CameraController 有 start(config) 之类的接口)
# 这里给一个最小的 config重点是 PTZ 相关字段
config = {
"ptz_host": ptz_host,
"ptz_port": ptz_port,
"ptz_user": ptz_user,
"ptz_password": ptz_password,
}
try:
cam.start(config)
except Exception as e:
print(f"[TEST] CameraController start() 失败: {e}", file=sys.stderr)
return
# 这里可以判断一下内部 _ptz 是否初始化成功(如果你对 CameraController 做了封装)
if getattr(cam, "_ptz", None) is None:
print("[TEST] CameraController 内部 PTZ 未初始化成功,请检查 ptz_host/port/user/password 配置。", file=sys.stderr)
return
# 3. 依次调用 CameraController 的 PTZ 方法
# 这里假设你在 CameraController 中提供了这几个对外方法:
# ptz_move_down / ptz_move_up / ptz_move_left / ptz_move_right
# 如果你命名不一样,把下面调用名改成你的即可。
print("向下移动(通过 CameraController...")
cam.ptz_move_down(speed=0.5, duration=1.0)
time.sleep(1)
print("向上移动(通过 CameraController...")
cam.ptz_move_up(speed=0.5, duration=1.0)
time.sleep(1)
print("向左移动(通过 CameraController...")
cam.ptz_move_left(speed=0.5, duration=1.0)
time.sleep(1)
print("向右移动(通过 CameraController...")
cam.ptz_move_right(speed=0.5, duration=1.0)
time.sleep(1)
print("测试结束。")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
测试 cameraDriver.py中的 PTZController 类,让摄像头按顺序运动几次
"""
import time
from cameraDriver import PTZController
def main():
# 根据你的实际情况填 IP、端口、账号密码
host = "192.168.31.164"
port = 80
user = "admin"
password = "admin123"
ptz = PTZController(host=host, port=port, user=user, password=password)
# 1. 连接摄像头
if not ptz.connect():
print("连接 PTZ 失败,检查 IP/用户名/密码/端口。")
return
# 2. 依次测试几个动作
# 每个动作之间 sleep 一下方便观察
print("向下移动...")
ptz.move_down(speed=0.5, duration=1.0)
time.sleep(1)
print("向上移动...")
ptz.move_up(speed=0.5, duration=1.0)
time.sleep(1)
print("向左移动...")
ptz.move_left(speed=0.5, duration=1.0)
time.sleep(1)
print("向右移动...")
ptz.move_right(speed=0.5, duration=1.0)
time.sleep(1)
print("测试结束。")
if __name__ == "__main__":
main()

View File

@@ -405,9 +405,19 @@ class RunningResultChecker(DriverChecker):
for i in range(self.driver._finished, temp):
sample_id = self.driver._get_resource_sample_id(self.driver._wf_name, i) # 从0开始计数
pdf, txt = self.driver.get_data_file(i + 1)
device_id = self.driver.device_id if hasattr(self.driver, "device_id") else "default"
oss_upload(pdf, f"hplc/{sample_id}/{os.path.basename(pdf)}", process_key="example", device_id=device_id)
oss_upload(txt, f"hplc/{sample_id}/{os.path.basename(txt)}", process_key="HPLC-txt-result", device_id=device_id)
# 使用新的OSS上传接口传入driver_name和exp_type
pdf_result = oss_upload(pdf, filename=os.path.basename(pdf), driver_name="HPLC", exp_type="analysis")
txt_result = oss_upload(txt, filename=os.path.basename(txt), driver_name="HPLC", exp_type="result")
if pdf_result["success"]:
print(f"PDF上传成功: {pdf_result['oss_path']}")
else:
print(f"PDF上传失败: {pdf_result['original_path']}")
if txt_result["success"]:
print(f"TXT上传成功: {txt_result['oss_path']}")
else:
print(f"TXT上传失败: {txt_result['original_path']}")
# self.driver.extract_data_from_txt()
except Exception as ex:
self.driver._finished = 0
@@ -456,8 +466,12 @@ if __name__ == "__main__":
}
sample_id = obj._get_resource_sample_id("test", 0)
pdf, txt = obj.get_data_file("1", after_time=datetime(2024, 11, 6, 19, 3, 6))
oss_upload(pdf, f"hplc/{sample_id}/{os.path.basename(pdf)}", process_key="example")
oss_upload(txt, f"hplc/{sample_id}/{os.path.basename(txt)}", process_key="HPLC-txt-result")
# 使用新的OSS上传接口传入driver_name和exp_type
pdf_result = oss_upload(pdf, filename=os.path.basename(pdf), driver_name="HPLC", exp_type="analysis")
txt_result = oss_upload(txt, filename=os.path.basename(txt), driver_name="HPLC", exp_type="result")
print(f"PDF上传结果: {pdf_result}")
print(f"TXT上传结果: {txt_result}")
# driver = HPLCDriver()
# for i in range(10000):
# print({k: v for k, v in driver._device_status.items() if isinstance(v, str)})

View File

@@ -1,307 +0,0 @@
"""
LaiYu_Liquid 液体处理工作站集成模块
该模块提供了 LaiYu_Liquid 工作站与 UniLabOS 的完整集成,包括:
- 硬件后端和抽象接口
- 资源定义和管理
- 协议执行和液体传输
- 工作台配置和布局
主要组件:
- LaiYuLiquidBackend: 硬件后端实现
- LaiYuLiquid: 液体处理器抽象接口
- 各种资源类:枪头架、板、容器等
- 便捷创建函数和配置管理
使用示例:
from unilabos.devices.laiyu_liquid import (
LaiYuLiquid,
LaiYuLiquidBackend,
create_standard_deck,
create_tip_rack_1000ul
)
# 创建后端和液体处理器
backend = LaiYuLiquidBackend()
lh = LaiYuLiquid(backend=backend)
# 创建工作台
deck = create_standard_deck()
lh.deck = deck
# 设置和运行
await lh.setup()
"""
# 版本信息
__version__ = "1.0.0"
__author__ = "LaiYu_Liquid Integration Team"
__description__ = "LaiYu_Liquid 液体处理工作站 UniLabOS 集成模块"
# 驱动程序导入
from .drivers import (
XYZStepperController,
SOPAPipette,
MotorAxis,
MotorStatus,
SOPAConfig,
SOPAStatusCode,
StepperMotorDriver
)
# 控制器导入
from .controllers import (
XYZController,
PipetteController,
)
# 后端导入
from .backend.rviz_backend import (
LiquidHandlerRvizBackend,
)
# 资源类和创建函数导入
from .core.laiyu_liquid_res import (
LaiYuLiquidDeck,
LaiYuLiquidContainer,
LaiYuLiquidTipRack
)
# 主设备类和配置
from .core.laiyu_liquid_main import (
LaiYuLiquid,
LaiYuLiquidConfig,
LaiYuLiquidDeck,
LaiYuLiquidContainer,
LaiYuLiquidTipRack,
create_quick_setup
)
# 后端创建函数导入
from .backend import (
LaiYuLiquidBackend,
create_laiyu_backend,
)
# 导出所有公共接口
__all__ = [
# 版本信息
"__version__",
"__author__",
"__description__",
# 驱动程序
"SOPAPipette",
"SOPAConfig",
"StepperMotorDriver",
"XYZStepperController",
# 控制器
"PipetteController",
"XYZController",
# 后端
"LiquidHandlerRvizBackend",
# 资源创建函数
"create_tip_rack_1000ul",
"create_tip_rack_200ul",
"create_96_well_plate",
"create_deep_well_plate",
"create_8_tube_rack",
"create_standard_deck",
"create_waste_container",
"create_wash_container",
"create_reagent_container",
"load_deck_config",
# 后端创建函数
"create_laiyu_backend",
# 主要类
"LaiYuLiquid",
"LaiYuLiquidConfig",
"LaiYuLiquidBackend",
"LaiYuLiquidDeck",
# 工具函数
"get_version",
"get_supported_resources",
"create_quick_setup",
"validate_installation",
"print_module_info",
"setup_logging",
]
# 别名定义,为了向后兼容
LaiYuLiquidDevice = LaiYuLiquid # 主设备类别名
LaiYuLiquidController = XYZController # 控制器别名
LaiYuLiquidDriver = XYZStepperController # 驱动器别名
# 模块级别的便捷函数
def get_version() -> str:
"""
获取模块版本
Returns:
str: 版本号
"""
return __version__
def get_supported_resources() -> dict:
"""
获取支持的资源类型
Returns:
dict: 支持的资源类型字典
"""
return {
"tip_racks": {
"LaiYuLiquidTipRack": LaiYuLiquidTipRack,
},
"containers": {
"LaiYuLiquidContainer": LaiYuLiquidContainer,
},
"decks": {
"LaiYuLiquidDeck": LaiYuLiquidDeck,
},
"devices": {
"LaiYuLiquid": LaiYuLiquid,
}
}
def create_quick_setup() -> tuple:
"""
快速创建基本设置
Returns:
tuple: (backend, controllers, resources) 的元组
"""
# 创建后端
backend = LiquidHandlerRvizBackend()
# 创建控制器(使用默认端口进行演示)
pipette_controller = PipetteController(port="/dev/ttyUSB0", address=4)
xyz_controller = XYZController(port="/dev/ttyUSB1", auto_connect=False)
# 创建测试资源
tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000")
tip_rack_200 = create_tip_rack_200ul("tip_rack_200")
well_plate = create_96_well_plate("96_well_plate")
controllers = {
'pipette': pipette_controller,
'xyz': xyz_controller
}
resources = {
'tip_rack_1000': tip_rack_1000,
'tip_rack_200': tip_rack_200,
'well_plate': well_plate
}
return backend, controllers, resources
def validate_installation() -> bool:
"""
验证模块安装是否正确
Returns:
bool: 安装是否正确
"""
try:
# 检查核心类是否可以导入
from .core.laiyu_liquid_main import LaiYuLiquid, LaiYuLiquidConfig
from .backend import LaiYuLiquidBackend
from .controllers import XYZController, PipetteController
from .drivers import XYZStepperController, SOPAPipette
# 尝试创建基本对象
config = LaiYuLiquidConfig()
backend = create_laiyu_backend("validation_test")
print("模块安装验证成功")
return True
except Exception as e:
print(f"模块安装验证失败: {e}")
return False
def print_module_info():
"""打印模块信息"""
print(f"LaiYu_Liquid 集成模块")
print(f"版本: {__version__}")
print(f"作者: {__author__}")
print(f"描述: {__description__}")
print(f"")
print(f"支持的资源类型:")
resources = get_supported_resources()
for category, types in resources.items():
print(f" {category}:")
for type_name, type_class in types.items():
print(f" - {type_name}: {type_class.__name__}")
print(f"")
print(f"主要功能:")
print(f" - 硬件集成: LaiYuLiquidBackend")
print(f" - 抽象接口: LaiYuLiquid")
print(f" - 资源管理: 各种资源类和创建函数")
print(f" - 协议执行: transfer_liquid 和相关函数")
print(f" - 配置管理: deck.json 和加载函数")
# 模块初始化时的检查
def _check_dependencies():
"""检查依赖项"""
try:
import pylabrobot
import asyncio
import json
import logging
return True
except ImportError as e:
import logging
logging.warning(f"缺少依赖项 {e}")
return False
# 执行依赖检查
_dependencies_ok = _check_dependencies()
if not _dependencies_ok:
import logging
logging.warning("某些依赖项缺失,模块功能可能受限")
# 模块级别的日志配置
import logging
def setup_logging(level: str = "INFO"):
"""
设置模块日志
Args:
level: 日志级别 (DEBUG, INFO, WARNING, ERROR)
"""
logger = logging.getLogger("LaiYu_Liquid")
logger.setLevel(getattr(logging, level.upper()))
if not logger.handlers:
handler = logging.StreamHandler()
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
# 默认日志设置
_logger = setup_logging()

View File

@@ -1,9 +0,0 @@
"""
LaiYu液体处理设备后端模块
提供设备后端接口和实现
"""
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']

View File

@@ -1,334 +0,0 @@
"""
LaiYu液体处理设备后端实现
提供设备的后端接口和控制逻辑
"""
import logging
from typing import Dict, Any, Optional, List
from abc import ABC, abstractmethod
# 尝试导入PyLabRobot后端
try:
from pylabrobot.liquid_handling.backends import LiquidHandlerBackend
PYLABROBOT_AVAILABLE = True
except ImportError:
PYLABROBOT_AVAILABLE = False
# 创建模拟后端基类
class LiquidHandlerBackend:
def __init__(self, name: str):
self.name = name
self.is_connected = False
def connect(self):
"""连接设备"""
pass
def disconnect(self):
"""断开连接"""
pass
class LaiYuLiquidBackend(LiquidHandlerBackend):
"""LaiYu液体处理设备后端"""
def __init__(self, name: str = "LaiYu_Liquid_Backend"):
"""
初始化LaiYu液体处理设备后端
Args:
name: 后端名称
"""
if PYLABROBOT_AVAILABLE:
# PyLabRobot 的 LiquidHandlerBackend 不接受参数
super().__init__()
else:
# 模拟版本接受 name 参数
super().__init__(name)
self.name = name
self.logger = logging.getLogger(__name__)
self.is_connected = False
self.device_info = {
"name": "LaiYu液体处理设备",
"version": "1.0.0",
"manufacturer": "LaiYu",
"model": "LaiYu_Liquid_Handler"
}
def connect(self) -> bool:
"""
连接到LaiYu液体处理设备
Returns:
bool: 连接是否成功
"""
try:
self.logger.info("正在连接到LaiYu液体处理设备...")
# 这里应该实现实际的设备连接逻辑
# 目前返回模拟连接成功
self.is_connected = True
self.logger.info("成功连接到LaiYu液体处理设备")
return True
except Exception as e:
self.logger.error(f"连接LaiYu液体处理设备失败: {e}")
self.is_connected = False
return False
def disconnect(self) -> bool:
"""
断开与LaiYu液体处理设备的连接
Returns:
bool: 断开连接是否成功
"""
try:
self.logger.info("正在断开与LaiYu液体处理设备的连接...")
# 这里应该实现实际的设备断开连接逻辑
self.is_connected = False
self.logger.info("成功断开与LaiYu液体处理设备的连接")
return True
except Exception as e:
self.logger.error(f"断开LaiYu液体处理设备连接失败: {e}")
return False
def is_device_connected(self) -> bool:
"""
检查设备是否已连接
Returns:
bool: 设备是否已连接
"""
return self.is_connected
def get_device_info(self) -> Dict[str, Any]:
"""
获取设备信息
Returns:
Dict[str, Any]: 设备信息字典
"""
return self.device_info.copy()
def home_device(self) -> bool:
"""
设备归零操作
Returns:
bool: 归零是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行归零操作")
return False
try:
self.logger.info("正在执行设备归零操作...")
# 这里应该实现实际的设备归零逻辑
self.logger.info("设备归零操作完成")
return True
except Exception as e:
self.logger.error(f"设备归零操作失败: {e}")
return False
def aspirate(self, volume: float, location: Dict[str, Any]) -> bool:
"""
吸液操作
Args:
volume: 吸液体积 (微升)
location: 吸液位置信息
Returns:
bool: 吸液是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行吸液操作")
return False
try:
self.logger.info(f"正在执行吸液操作: 体积={volume}μL, 位置={location}")
# 这里应该实现实际的吸液逻辑
self.logger.info("吸液操作完成")
return True
except Exception as e:
self.logger.error(f"吸液操作失败: {e}")
return False
def dispense(self, volume: float, location: Dict[str, Any]) -> bool:
"""
排液操作
Args:
volume: 排液体积 (微升)
location: 排液位置信息
Returns:
bool: 排液是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行排液操作")
return False
try:
self.logger.info(f"正在执行排液操作: 体积={volume}μL, 位置={location}")
# 这里应该实现实际的排液逻辑
self.logger.info("排液操作完成")
return True
except Exception as e:
self.logger.error(f"排液操作失败: {e}")
return False
def pick_up_tip(self, location: Dict[str, Any]) -> bool:
"""
取枪头操作
Args:
location: 枪头位置信息
Returns:
bool: 取枪头是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行取枪头操作")
return False
try:
self.logger.info(f"正在执行取枪头操作: 位置={location}")
# 这里应该实现实际的取枪头逻辑
self.logger.info("取枪头操作完成")
return True
except Exception as e:
self.logger.error(f"取枪头操作失败: {e}")
return False
def drop_tip(self, location: Dict[str, Any]) -> bool:
"""
丢弃枪头操作
Args:
location: 丢弃位置信息
Returns:
bool: 丢弃枪头是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行丢弃枪头操作")
return False
try:
self.logger.info(f"正在执行丢弃枪头操作: 位置={location}")
# 这里应该实现实际的丢弃枪头逻辑
self.logger.info("丢弃枪头操作完成")
return True
except Exception as e:
self.logger.error(f"丢弃枪头操作失败: {e}")
return False
def move_to(self, location: Dict[str, Any]) -> bool:
"""
移动到指定位置
Args:
location: 目标位置信息
Returns:
bool: 移动是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行移动操作")
return False
try:
self.logger.info(f"正在移动到位置: {location}")
# 这里应该实现实际的移动逻辑
self.logger.info("移动操作完成")
return True
except Exception as e:
self.logger.error(f"移动操作失败: {e}")
return False
def get_status(self) -> Dict[str, Any]:
"""
获取设备状态
Returns:
Dict[str, Any]: 设备状态信息
"""
return {
"connected": self.is_connected,
"device_info": self.device_info,
"status": "ready" if self.is_connected else "disconnected"
}
# PyLabRobot 抽象方法实现
def stop(self):
"""停止所有操作"""
self.logger.info("停止所有操作")
pass
@property
def num_channels(self) -> int:
"""返回通道数量"""
return 1 # 单通道移液器
def can_pick_up_tip(self, tip_rack, tip_position) -> bool:
"""检查是否可以拾取吸头"""
return True # 简化实现总是返回True
def pick_up_tips(self, tip_rack, tip_positions):
"""拾取多个吸头"""
self.logger.info(f"拾取吸头: {tip_positions}")
pass
def drop_tips(self, tip_rack, tip_positions):
"""丢弃多个吸头"""
self.logger.info(f"丢弃吸头: {tip_positions}")
pass
def pick_up_tips96(self, tip_rack):
"""拾取96个吸头"""
self.logger.info("拾取96个吸头")
pass
def drop_tips96(self, tip_rack):
"""丢弃96个吸头"""
self.logger.info("丢弃96个吸头")
pass
def aspirate96(self, volume, plate, well_positions):
"""96通道吸液"""
self.logger.info(f"96通道吸液: 体积={volume}")
pass
def dispense96(self, volume, plate, well_positions):
"""96通道排液"""
self.logger.info(f"96通道排液: 体积={volume}")
pass
def pick_up_resource(self, resource, location):
"""拾取资源"""
self.logger.info(f"拾取资源: {resource}")
pass
def drop_resource(self, resource, location):
"""放置资源"""
self.logger.info(f"放置资源: {resource}")
pass
def move_picked_up_resource(self, resource, location):
"""移动已拾取的资源"""
self.logger.info(f"移动资源: {resource}{location}")
pass
def create_laiyu_backend(name: str = "LaiYu_Liquid_Backend") -> LaiYuLiquidBackend:
"""
创建LaiYu液体处理设备后端实例
Args:
name: 后端名称
Returns:
LaiYuLiquidBackend: 后端实例
"""
return LaiYuLiquidBackend(name)

View File

@@ -1,209 +0,0 @@
import json
from typing import List, Optional, Union
from pylabrobot.liquid_handling.backends.backend import (
LiquidHandlerBackend,
)
from pylabrobot.liquid_handling.standard import (
Drop,
DropTipRack,
MultiHeadAspirationContainer,
MultiHeadAspirationPlate,
MultiHeadDispenseContainer,
MultiHeadDispensePlate,
Pickup,
PickupTipRack,
ResourceDrop,
ResourceMove,
ResourcePickup,
SingleChannelAspiration,
SingleChannelDispense,
)
from pylabrobot.resources import Resource, Tip
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
import time
from rclpy.action import ActionClient
from unilabos_msgs.action import SendCmd
import re
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
class LiquidHandlerRvizBackend(LiquidHandlerBackend):
"""Chatter box backend for device-free testing. Prints out all operations."""
_pip_length = 5
_vol_length = 8
_resource_length = 20
_offset_length = 16
_flow_rate_length = 10
_blowout_length = 10
_lld_z_length = 10
_kwargs_length = 15
_tip_type_length = 12
_max_volume_length = 16
_fitting_depth_length = 20
_tip_length_length = 16
# _pickup_method_length = 20
_filter_length = 10
def __init__(self, num_channels: int = 8):
"""Initialize a chatter box backend."""
super().__init__()
self._num_channels = num_channels
# rclpy.init()
if not rclpy.ok():
rclpy.init()
self.joint_state_publisher = None
async def setup(self):
self.joint_state_publisher = JointStatePublisher()
await super().setup()
async def stop(self):
pass
def serialize(self) -> dict:
return {**super().serialize(), "num_channels": self.num_channels}
@property
def num_channels(self) -> int:
return self._num_channels
async def assigned_resource_callback(self, resource: Resource):
pass
async def unassigned_resource_callback(self, name: str):
pass
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
for op, channel in zip(ops, use_channels):
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
row = (
f" p{channel}: "
f"{op.resource.name[-30:]:<{LiquidHandlerRvizBackend._resource_length}} "
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
f"{op.tip.__class__.__name__:<{LiquidHandlerRvizBackend._tip_type_length}} "
f"{op.tip.maximal_volume:<{LiquidHandlerRvizBackend._max_volume_length}} "
f"{op.tip.fitting_depth:<{LiquidHandlerRvizBackend._fitting_depth_length}} "
f"{op.tip.total_tip_length:<{LiquidHandlerRvizBackend._tip_length_length}} "
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
f"{'Yes' if op.tip.has_filter else 'No':<{LiquidHandlerRvizBackend._filter_length}}"
)
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
x = coordinate.x
y = coordinate.y
z = coordinate.z + 70
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick")
# goback()
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
x = coordinate.x
y = coordinate.y
z = coordinate.z + 70
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "drop_trash")
# goback()
async def aspirate(
self,
ops: List[SingleChannelAspiration],
use_channels: List[int],
**backend_kwargs,
):
# 执行吸液操作
pass
for o, p in zip(ops, use_channels):
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
row = (
f" p{p}: "
f"{o.volume:<{LiquidHandlerRvizBackend._vol_length}} "
f"{o.resource.name[-20:]:<{LiquidHandlerRvizBackend._resource_length}} "
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
f"{str(o.flow_rate):<{LiquidHandlerRvizBackend._flow_rate_length}} "
f"{str(o.blow_out_air_volume):<{LiquidHandlerRvizBackend._blowout_length}} "
f"{str(o.liquid_height):<{LiquidHandlerRvizBackend._lld_z_length}} "
# f"{o.liquids if o.liquids is not None else 'none'}"
)
for key, value in backend_kwargs.items():
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
value = "".join("T" if v else "F" for v in value)
if isinstance(value, list):
value = "".join(map(str, value))
row += f" {value:<15}"
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
x = coordinate.x
y = coordinate.y
z = coordinate.z + 70
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "")
async def dispense(
self,
ops: List[SingleChannelDispense],
use_channels: List[int],
**backend_kwargs,
):
for o, p in zip(ops, use_channels):
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
row = (
f" p{p}: "
f"{o.volume:<{LiquidHandlerRvizBackend._vol_length}} "
f"{o.resource.name[-20:]:<{LiquidHandlerRvizBackend._resource_length}} "
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
f"{str(o.flow_rate):<{LiquidHandlerRvizBackend._flow_rate_length}} "
f"{str(o.blow_out_air_volume):<{LiquidHandlerRvizBackend._blowout_length}} "
f"{str(o.liquid_height):<{LiquidHandlerRvizBackend._lld_z_length}} "
# f"{o.liquids if o.liquids is not None else 'none'}"
)
for key, value in backend_kwargs.items():
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
value = "".join("T" if v else "F" for v in value)
if isinstance(value, list):
value = "".join(map(str, value))
row += f" {value:<{LiquidHandlerRvizBackend._kwargs_length}}"
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
x = coordinate.x
y = coordinate.y
z = coordinate.z + 70
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "")
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
pass
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
pass
async def aspirate96(
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
):
pass
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
pass
async def pick_up_resource(self, pickup: ResourcePickup):
# 执行资源拾取操作
pass
async def move_picked_up_resource(self, move: ResourceMove):
# 执行资源移动操作
pass
async def drop_resource(self, drop: ResourceDrop):
# 执行资源放置操作
pass
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
return True

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +0,0 @@
goto 171 178 57 H1
goto 171 117 57 A1
goto 172 178 130
goto 173 179 133
goto 173 180 133
goto 173 180 138
goto 173 180 125 +10mm在空的上面边缘
goto 173 180 130 取不到
goto 173 180 133 取不到
goto 173 180 135
goto 173 180 137 取到了!!!!
goto 173 180 131 弹出枪头 H1
goto 173 117 137 A1 +10mm可以取到新枪头了

View File

@@ -1,25 +0,0 @@
"""
LaiYu_Liquid 控制器模块
该模块包含了LaiYu_Liquid液体处理工作站的高级控制器
- 移液器控制器:提供液体处理的高级接口
- XYZ运动控制器提供三轴运动的高级接口
"""
# 移液器控制器导入
from .pipette_controller import PipetteController
# XYZ运动控制器导入
from .xyz_controller import XYZController
__all__ = [
# 移液器控制器
"PipetteController",
# XYZ运动控制器
"XYZController",
]
__version__ = "1.0.0"
__author__ = "LaiYu_Liquid Controller Team"
__description__ = "LaiYu_Liquid 高级控制器集合"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
LaiYu液体处理设备核心模块
该模块包含LaiYu液体处理设备的核心功能组件
- LaiYu_Liquid.py: 主设备类和配置管理
- abstract_protocol.py: 抽象协议定义
- laiyu_liquid_res.py: 设备资源管理
作者: UniLab团队
版本: 2.0.0
"""
from .laiyu_liquid_main import (
LaiYuLiquid,
LaiYuLiquidConfig,
LaiYuLiquidBackend,
LaiYuLiquidDeck,
LaiYuLiquidContainer,
LaiYuLiquidTipRack,
create_quick_setup
)
from .laiyu_liquid_res import (
LaiYuLiquidDeck,
LaiYuLiquidContainer,
LaiYuLiquidTipRack
)
__all__ = [
# 主设备类
'LaiYuLiquid',
'LaiYuLiquidConfig',
'LaiYuLiquidBackend',
# 设备资源
'LaiYuLiquidDeck',
'LaiYuLiquidContainer',
'LaiYuLiquidTipRack',
# 工具函数
'create_quick_setup'
]

View File

@@ -1,529 +0,0 @@
"""
LaiYu_Liquid 抽象协议实现
该模块提供了液体资源管理和转移的抽象协议,包括:
- MaterialResource: 液体资源管理类
- transfer_liquid: 液体转移函数
- 相关的辅助类和函数
主要功能:
- 管理多孔位的液体资源
- 计算和跟踪液体体积
- 处理液体转移操作
- 提供资源状态查询
"""
import logging
from typing import Dict, List, Optional, Union, Any, Tuple
from dataclasses import dataclass, field
from enum import Enum
import uuid
import time
# pylabrobot 导入
from pylabrobot.resources import Resource, Well, Plate
logger = logging.getLogger(__name__)
class LiquidType(Enum):
"""液体类型枚举"""
WATER = "water"
ETHANOL = "ethanol"
DMSO = "dmso"
BUFFER = "buffer"
SAMPLE = "sample"
REAGENT = "reagent"
WASTE = "waste"
UNKNOWN = "unknown"
@dataclass
class LiquidInfo:
"""液体信息类"""
liquid_type: LiquidType = LiquidType.UNKNOWN
volume: float = 0.0 # 体积 (μL)
concentration: Optional[float] = None # 浓度 (mg/ml, M等)
ph: Optional[float] = None # pH值
temperature: Optional[float] = None # 温度 (°C)
viscosity: Optional[float] = None # 粘度 (cP)
density: Optional[float] = None # 密度 (g/ml)
description: str = "" # 描述信息
def __str__(self) -> str:
return f"{self.liquid_type.value}({self.description})"
@dataclass
class WellContent:
"""孔位内容类"""
volume: float = 0.0 # 当前体积 (ul)
max_volume: float = 1000.0 # 最大容量 (ul)
liquid_info: LiquidInfo = field(default_factory=LiquidInfo)
last_updated: float = field(default_factory=time.time)
@property
def is_empty(self) -> bool:
"""检查是否为空"""
return self.volume <= 0.0
@property
def is_full(self) -> bool:
"""检查是否已满"""
return self.volume >= self.max_volume
@property
def available_volume(self) -> float:
"""可用体积"""
return max(0.0, self.max_volume - self.volume)
@property
def fill_percentage(self) -> float:
"""填充百分比"""
return (self.volume / self.max_volume) * 100.0 if self.max_volume > 0 else 0.0
def can_add_volume(self, volume: float) -> bool:
"""检查是否可以添加指定体积"""
return (self.volume + volume) <= self.max_volume
def can_remove_volume(self, volume: float) -> bool:
"""检查是否可以移除指定体积"""
return self.volume >= volume
def add_volume(self, volume: float, liquid_info: Optional[LiquidInfo] = None) -> bool:
"""
添加液体体积
Args:
volume: 要添加的体积 (ul)
liquid_info: 液体信息
Returns:
bool: 是否成功添加
"""
if not self.can_add_volume(volume):
return False
self.volume += volume
if liquid_info:
self.liquid_info = liquid_info
self.last_updated = time.time()
return True
def remove_volume(self, volume: float) -> bool:
"""
移除液体体积
Args:
volume: 要移除的体积 (ul)
Returns:
bool: 是否成功移除
"""
if not self.can_remove_volume(volume):
return False
self.volume -= volume
self.last_updated = time.time()
# 如果完全清空,重置液体信息
if self.volume <= 0.0:
self.volume = 0.0
self.liquid_info = LiquidInfo()
return True
class MaterialResource:
"""
液体资源管理类
该类用于管理液体处理过程中的资源状态,包括:
- 跟踪多个孔位的液体体积和类型
- 计算总体积和可用体积
- 处理液体的添加和移除
- 提供资源状态查询
"""
def __init__(
self,
resource: Resource,
wells: Optional[List[Well]] = None,
default_max_volume: float = 1000.0
):
"""
初始化材料资源
Args:
resource: pylabrobot 资源对象
wells: 孔位列表如果为None则自动获取
default_max_volume: 默认最大体积 (ul)
"""
self.resource = resource
self.resource_id = str(uuid.uuid4())
self.default_max_volume = default_max_volume
# 获取孔位列表
if wells is None:
if hasattr(resource, 'get_wells'):
self.wells = resource.get_wells()
elif hasattr(resource, 'wells'):
self.wells = resource.wells
else:
# 如果没有孔位,创建一个虚拟孔位
self.wells = [resource]
else:
self.wells = wells
# 初始化孔位内容
self.well_contents: Dict[str, WellContent] = {}
for well in self.wells:
well_id = self._get_well_id(well)
self.well_contents[well_id] = WellContent(
max_volume=default_max_volume
)
logger.info(f"初始化材料资源: {resource.name}, 孔位数: {len(self.wells)}")
def _get_well_id(self, well: Union[Well, Resource]) -> str:
"""获取孔位ID"""
if hasattr(well, 'name'):
return well.name
else:
return str(id(well))
@property
def name(self) -> str:
"""资源名称"""
return self.resource.name
@property
def total_volume(self) -> float:
"""总液体体积"""
return sum(content.volume for content in self.well_contents.values())
@property
def total_max_volume(self) -> float:
"""总最大容量"""
return sum(content.max_volume for content in self.well_contents.values())
@property
def available_volume(self) -> float:
"""总可用体积"""
return sum(content.available_volume for content in self.well_contents.values())
@property
def well_count(self) -> int:
"""孔位数量"""
return len(self.wells)
@property
def empty_wells(self) -> List[str]:
"""空孔位列表"""
return [well_id for well_id, content in self.well_contents.items()
if content.is_empty]
@property
def full_wells(self) -> List[str]:
"""满孔位列表"""
return [well_id for well_id, content in self.well_contents.items()
if content.is_full]
@property
def occupied_wells(self) -> List[str]:
"""有液体的孔位列表"""
return [well_id for well_id, content in self.well_contents.items()
if not content.is_empty]
def get_well_content(self, well_id: str) -> Optional[WellContent]:
"""获取指定孔位的内容"""
return self.well_contents.get(well_id)
def get_well_volume(self, well_id: str) -> float:
"""获取指定孔位的体积"""
content = self.get_well_content(well_id)
return content.volume if content else 0.0
def set_well_volume(
self,
well_id: str,
volume: float,
liquid_info: Optional[LiquidInfo] = None
) -> bool:
"""
设置指定孔位的体积
Args:
well_id: 孔位ID
volume: 体积 (ul)
liquid_info: 液体信息
Returns:
bool: 是否成功设置
"""
if well_id not in self.well_contents:
logger.error(f"孔位 {well_id} 不存在")
return False
content = self.well_contents[well_id]
if volume > content.max_volume:
logger.error(f"体积 {volume} 超过最大容量 {content.max_volume}")
return False
content.volume = max(0.0, volume)
if liquid_info:
content.liquid_info = liquid_info
content.last_updated = time.time()
logger.info(f"设置孔位 {well_id} 体积: {volume}ul")
return True
def add_liquid(
self,
well_id: str,
volume: float,
liquid_info: Optional[LiquidInfo] = None
) -> bool:
"""
向指定孔位添加液体
Args:
well_id: 孔位ID
volume: 添加的体积 (ul)
liquid_info: 液体信息
Returns:
bool: 是否成功添加
"""
if well_id not in self.well_contents:
logger.error(f"孔位 {well_id} 不存在")
return False
content = self.well_contents[well_id]
success = content.add_volume(volume, liquid_info)
if success:
logger.info(f"向孔位 {well_id} 添加 {volume}ul 液体")
else:
logger.error(f"无法向孔位 {well_id} 添加 {volume}ul 液体")
return success
def remove_liquid(self, well_id: str, volume: float) -> bool:
"""
从指定孔位移除液体
Args:
well_id: 孔位ID
volume: 移除的体积 (ul)
Returns:
bool: 是否成功移除
"""
if well_id not in self.well_contents:
logger.error(f"孔位 {well_id} 不存在")
return False
content = self.well_contents[well_id]
success = content.remove_volume(volume)
if success:
logger.info(f"从孔位 {well_id} 移除 {volume}ul 液体")
else:
logger.error(f"无法从孔位 {well_id} 移除 {volume}ul 液体")
return success
def find_wells_with_volume(self, min_volume: float) -> List[str]:
"""
查找具有指定最小体积的孔位
Args:
min_volume: 最小体积 (ul)
Returns:
List[str]: 符合条件的孔位ID列表
"""
return [well_id for well_id, content in self.well_contents.items()
if content.volume >= min_volume]
def find_wells_with_space(self, min_space: float) -> List[str]:
"""
查找具有指定最小空间的孔位
Args:
min_space: 最小空间 (ul)
Returns:
List[str]: 符合条件的孔位ID列表
"""
return [well_id for well_id, content in self.well_contents.items()
if content.available_volume >= min_space]
def get_status_summary(self) -> Dict[str, Any]:
"""获取资源状态摘要"""
return {
"resource_name": self.name,
"resource_id": self.resource_id,
"well_count": self.well_count,
"total_volume": self.total_volume,
"total_max_volume": self.total_max_volume,
"available_volume": self.available_volume,
"fill_percentage": (self.total_volume / self.total_max_volume) * 100.0,
"empty_wells": len(self.empty_wells),
"full_wells": len(self.full_wells),
"occupied_wells": len(self.occupied_wells)
}
def get_detailed_status(self) -> Dict[str, Any]:
"""获取详细状态信息"""
well_details = {}
for well_id, content in self.well_contents.items():
well_details[well_id] = {
"volume": content.volume,
"max_volume": content.max_volume,
"available_volume": content.available_volume,
"fill_percentage": content.fill_percentage,
"liquid_type": content.liquid_info.liquid_type.value,
"description": content.liquid_info.description,
"last_updated": content.last_updated
}
return {
"summary": self.get_status_summary(),
"wells": well_details
}
def transfer_liquid(
source: MaterialResource,
target: MaterialResource,
volume: float,
source_well_id: Optional[str] = None,
target_well_id: Optional[str] = None,
liquid_info: Optional[LiquidInfo] = None
) -> bool:
"""
在两个材料资源之间转移液体
Args:
source: 源资源
target: 目标资源
volume: 转移体积 (ul)
source_well_id: 源孔位ID如果为None则自动选择
target_well_id: 目标孔位ID如果为None则自动选择
liquid_info: 液体信息
Returns:
bool: 转移是否成功
"""
try:
# 自动选择源孔位
if source_well_id is None:
available_wells = source.find_wells_with_volume(volume)
if not available_wells:
logger.error(f"源资源 {source.name} 没有足够体积的孔位")
return False
source_well_id = available_wells[0]
# 自动选择目标孔位
if target_well_id is None:
available_wells = target.find_wells_with_space(volume)
if not available_wells:
logger.error(f"目标资源 {target.name} 没有足够空间的孔位")
return False
target_well_id = available_wells[0]
# 检查源孔位是否有足够液体
if not source.get_well_content(source_well_id).can_remove_volume(volume):
logger.error(f"源孔位 {source_well_id} 液体不足")
return False
# 检查目标孔位是否有足够空间
if not target.get_well_content(target_well_id).can_add_volume(volume):
logger.error(f"目标孔位 {target_well_id} 空间不足")
return False
# 获取源液体信息
source_content = source.get_well_content(source_well_id)
transfer_liquid_info = liquid_info or source_content.liquid_info
# 执行转移
if source.remove_liquid(source_well_id, volume):
if target.add_liquid(target_well_id, volume, transfer_liquid_info):
logger.info(f"成功转移 {volume}ul 液体: {source.name}[{source_well_id}] -> {target.name}[{target_well_id}]")
return True
else:
# 如果目标添加失败,回滚源操作
source.add_liquid(source_well_id, volume, source_content.liquid_info)
logger.error("目标添加失败,已回滚源操作")
return False
else:
logger.error("源移除失败")
return False
except Exception as e:
logger.error(f"液体转移失败: {e}")
return False
def create_material_resource(
name: str,
resource: Resource,
initial_volumes: Optional[Dict[str, float]] = None,
liquid_info: Optional[LiquidInfo] = None,
max_volume: float = 1000.0
) -> MaterialResource:
"""
创建材料资源的便捷函数
Args:
name: 资源名称
resource: pylabrobot 资源对象
initial_volumes: 初始体积字典 {well_id: volume}
liquid_info: 液体信息
max_volume: 最大体积
Returns:
MaterialResource: 创建的材料资源
"""
material_resource = MaterialResource(
resource=resource,
default_max_volume=max_volume
)
# 设置初始体积
if initial_volumes:
for well_id, volume in initial_volumes.items():
material_resource.set_well_volume(well_id, volume, liquid_info)
return material_resource
def batch_transfer_liquid(
transfers: List[Tuple[MaterialResource, MaterialResource, float]],
liquid_info: Optional[LiquidInfo] = None
) -> List[bool]:
"""
批量液体转移
Args:
transfers: 转移列表 [(source, target, volume), ...]
liquid_info: 液体信息
Returns:
List[bool]: 每个转移操作的结果
"""
results = []
for source, target, volume in transfers:
result = transfer_liquid(source, target, volume, liquid_info=liquid_info)
results.append(result)
if not result:
logger.warning(f"批量转移中的操作失败: {source.name} -> {target.name}")
success_count = sum(results)
logger.info(f"批量转移完成: {success_count}/{len(transfers)} 成功")
return results

View File

@@ -1,888 +0,0 @@
"""
LaiYu_Liquid 液体处理工作站主要集成文件
该模块实现了 LaiYu_Liquid 与 UniLabOS 系统的集成,提供标准化的液体处理接口。
主要包含:
- LaiYuLiquidBackend: 硬件通信后端
- LaiYuLiquid: 主要接口类
- 相关的异常类和容器类
"""
import asyncio
import logging
import time
from typing import List, Optional, Dict, Any, Union, Tuple
from dataclasses import dataclass
from abc import ABC, abstractmethod
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
# 基础导入
try:
from pylabrobot.resources import Deck, Plate, TipRack, Tip, Resource, Well
PYLABROBOT_AVAILABLE = True
except ImportError:
# 如果 pylabrobot 不可用,创建基础的模拟类
PYLABROBOT_AVAILABLE = False
class Resource:
def __init__(self, name: str):
self.name = name
class Deck(Resource):
pass
class Plate(Resource):
pass
class TipRack(Resource):
pass
class Tip(Resource):
pass
class Well(Resource):
pass
# LaiYu_Liquid 控制器导入
try:
from .controllers.pipette_controller import PipetteController, TipStatus, LiquidClass, LiquidParameters
from .controllers.xyz_controller import XYZController, MachineConfig, CoordinateOrigin, MotorAxis
CONTROLLERS_AVAILABLE = True
except ImportError:
CONTROLLERS_AVAILABLE = False
# 创建模拟的控制器类
class PipetteController:
def __init__(self, *args, **kwargs):
pass
def connect(self):
return True
def initialize(self):
return True
class XYZController:
def __init__(self, *args, **kwargs):
pass
def connect_device(self):
return True
logger = logging.getLogger(__name__)
class LaiYuLiquidError(RuntimeError):
"""LaiYu_Liquid 设备异常"""
pass
@dataclass
class LaiYuLiquidConfig:
"""LaiYu_Liquid 设备配置"""
port: str = "/dev/cu.usbserial-3130" # RS485转USB端口
address: int = 1 # 设备地址
baudrate: int = 9600 # 波特率
timeout: float = 5.0 # 通信超时时间
# 工作台尺寸
deck_width: float = 340.0 # 工作台宽度 (mm)
deck_height: float = 250.0 # 工作台高度 (mm)
deck_depth: float = 160.0 # 工作台深度 (mm)
# 移液参数
max_volume: float = 1000.0 # 最大体积 (μL)
min_volume: float = 0.1 # 最小体积 (μL)
# 运动参数
max_speed: float = 100.0 # 最大速度 (mm/s)
acceleration: float = 50.0 # 加速度 (mm/s²)
# 安全参数
safe_height: float = 50.0 # 安全高度 (mm)
tip_pickup_depth: float = 10.0 # 吸头拾取深度 (mm)
liquid_detection: bool = True # 液面检测
# 取枪头相关参数
tip_pickup_speed: int = 30 # 取枪头时的移动速度 (rpm)
tip_pickup_acceleration: int = 500 # 取枪头时的加速度 (rpm/s)
tip_approach_height: float = 5.0 # 接近枪头时的高度 (mm)
tip_pickup_force_depth: float = 2.0 # 强制插入深度 (mm)
tip_pickup_retract_height: float = 20.0 # 取枪头后的回退高度 (mm)
# 丢弃枪头相关参数
tip_drop_height: float = 10.0 # 丢弃枪头时的高度 (mm)
tip_drop_speed: int = 50 # 丢弃枪头时的移动速度 (rpm)
trash_position: Tuple[float, float, float] = (300.0, 200.0, 0.0) # 垃圾桶位置 (mm)
# 安全范围配置
deck_width: float = 300.0 # 工作台宽度 (mm)
deck_height: float = 200.0 # 工作台高度 (mm)
deck_depth: float = 100.0 # 工作台深度 (mm)
safe_height: float = 50.0 # 安全高度 (mm)
position_validation: bool = True # 启用位置验证
emergency_stop_enabled: bool = True # 启用紧急停止
class LaiYuLiquidDeck:
"""LaiYu_Liquid 工作台管理"""
def __init__(self, config: LaiYuLiquidConfig):
self.config = config
self.resources: Dict[str, Resource] = {}
self.positions: Dict[str, Tuple[float, float, float]] = {}
def add_resource(self, name: str, resource: Resource, position: Tuple[float, float, float]):
"""添加资源到工作台"""
self.resources[name] = resource
self.positions[name] = position
def get_resource(self, name: str) -> Optional[Resource]:
"""获取资源"""
return self.resources.get(name)
def get_position(self, name: str) -> Optional[Tuple[float, float, float]]:
"""获取资源位置"""
return self.positions.get(name)
def list_resources(self) -> List[str]:
"""列出所有资源"""
return list(self.resources.keys())
class LaiYuLiquidContainer:
"""LaiYu_Liquid 容器类"""
def __init__(
self,
name: str,
size_x: float = 0,
size_y: float = 0,
size_z: float = 0,
container_type: str = "",
volume: float = 0.0,
max_volume: float = 1000.0,
lid_height: float = 0.0,
):
self.name = name
self.size_x = size_x
self.size_y = size_y
self.size_z = size_z
self.lid_height = lid_height
self.container_type = container_type
self.volume = volume
self.max_volume = max_volume
self.last_updated = time.time()
self.child_resources = {} # 存储子资源
@property
def is_empty(self) -> bool:
return self.volume <= 0.0
@property
def is_full(self) -> bool:
return self.volume >= self.max_volume
@property
def available_volume(self) -> float:
return max(0.0, self.max_volume - self.volume)
def add_volume(self, volume: float) -> bool:
"""添加体积"""
if self.volume + volume <= self.max_volume:
self.volume += volume
self.last_updated = time.time()
return True
return False
def remove_volume(self, volume: float) -> bool:
"""移除体积"""
if self.volume >= volume:
self.volume -= volume
self.last_updated = time.time()
return True
return False
def assign_child_resource(self, resource, location=None):
"""分配子资源 - 与 PyLabRobot 资源管理系统兼容"""
if hasattr(resource, "name"):
self.child_resources[resource.name] = {"resource": resource, "location": location}
class LaiYuLiquidTipRack:
"""LaiYu_Liquid 吸头架类"""
def __init__(
self,
name: str,
size_x: float = 0,
size_y: float = 0,
size_z: float = 0,
tip_count: int = 96,
tip_volume: float = 1000.0,
):
self.name = name
self.size_x = size_x
self.size_y = size_y
self.size_z = size_z
self.tip_count = tip_count
self.tip_volume = tip_volume
self.tips_available = [True] * tip_count
self.child_resources = {} # 存储子资源
@property
def available_tips(self) -> int:
return sum(self.tips_available)
@property
def is_empty(self) -> bool:
return self.available_tips == 0
def pick_tip(self, position: int) -> bool:
"""拾取吸头"""
if 0 <= position < self.tip_count and self.tips_available[position]:
self.tips_available[position] = False
return True
return False
def has_tip(self, position: int) -> bool:
"""检查位置是否有吸头"""
if 0 <= position < self.tip_count:
return self.tips_available[position]
return False
def assign_child_resource(self, resource, location=None):
"""分配子资源到指定位置"""
self.child_resources[resource.name] = {"resource": resource, "location": location}
def get_module_info():
"""获取模块信息"""
return {
"name": "LaiYu_Liquid",
"version": "1.0.0",
"description": "LaiYu液体处理工作站模块提供移液器控制、XYZ轴控制和资源管理功能",
"author": "UniLabOS Team",
"capabilities": ["移液器控制", "XYZ轴运动控制", "吸头架管理", "板和容器管理", "资源位置管理"],
"dependencies": {"required": ["serial"], "optional": ["pylabrobot"]},
}
class LaiYuLiquidBackend:
"""LaiYu_Liquid 硬件通信后端"""
_ros_node: BaseROS2DeviceNode
def __init__(self, config: LaiYuLiquidConfig, deck: Optional["LaiYuLiquidDeck"] = None):
self.config = config
self.deck = deck # 工作台引用,用于获取资源位置信息
self.pipette_controller = None
self.xyz_controller = None
self.is_connected = False
self.is_initialized = False
# 状态跟踪
self.current_position = (0.0, 0.0, 0.0)
self.tip_attached = False
self.current_volume = 0.0
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
def _validate_position(self, x: float, y: float, z: float) -> bool:
"""验证位置是否在安全范围内"""
try:
# 检查X轴范围
if not (0 <= x <= self.config.deck_width):
logger.error(f"X轴位置 {x:.2f}mm 超出范围 [0, {self.config.deck_width}]")
return False
# 检查Y轴范围
if not (0 <= y <= self.config.deck_height):
logger.error(f"Y轴位置 {y:.2f}mm 超出范围 [0, {self.config.deck_height}]")
return False
# 检查Z轴范围负值表示向下0为工作台表面
if not (-self.config.deck_depth <= z <= self.config.safe_height):
logger.error(f"Z轴位置 {z:.2f}mm 超出安全范围 [{-self.config.deck_depth}, {self.config.safe_height}]")
return False
return True
except Exception as e:
logger.error(f"位置验证失败: {e}")
return False
def _check_hardware_ready(self) -> bool:
"""检查硬件是否准备就绪"""
if not self.is_connected:
logger.error("设备未连接")
return False
if CONTROLLERS_AVAILABLE:
if self.xyz_controller is None:
logger.error("XYZ控制器未初始化")
return False
return True
async def emergency_stop(self) -> bool:
"""紧急停止所有运动"""
try:
logger.warning("执行紧急停止")
if CONTROLLERS_AVAILABLE and self.xyz_controller:
# 停止XYZ控制器
await self.xyz_controller.stop_all_motion()
logger.info("XYZ控制器已停止")
if self.pipette_controller:
# 停止移液器控制器
await self.pipette_controller.stop()
logger.info("移液器控制器已停止")
return True
except Exception as e:
logger.error(f"紧急停止失败: {e}")
return False
async def move_to_safe_position(self) -> bool:
"""移动到安全位置"""
try:
if not self._check_hardware_ready():
return False
safe_position = (
self.config.deck_width / 2, # 工作台中心X
self.config.deck_height / 2, # 工作台中心Y
self.config.safe_height, # 安全高度Z
)
if not self._validate_position(*safe_position):
logger.error("安全位置无效")
return False
if CONTROLLERS_AVAILABLE and self.xyz_controller:
await self.xyz_controller.move_to_work_coord(*safe_position)
self.current_position = safe_position
logger.info(f"已移动到安全位置: {safe_position}")
return True
else:
# 模拟模式
self.current_position = safe_position
logger.info("模拟移动到安全位置")
return True
except Exception as e:
logger.error(f"移动到安全位置失败: {e}")
return False
async def setup(self) -> bool:
"""设置硬件连接"""
try:
if CONTROLLERS_AVAILABLE:
# 初始化移液器控制器
self.pipette_controller = PipetteController(port=self.config.port, address=self.config.address)
# 初始化XYZ控制器
machine_config = MachineConfig()
self.xyz_controller = XYZController(
port=self.config.port, baudrate=self.config.baudrate, machine_config=machine_config
)
# 连接设备
pipette_connected = await asyncio.to_thread(self.pipette_controller.connect)
xyz_connected = await asyncio.to_thread(self.xyz_controller.connect_device)
if pipette_connected and xyz_connected:
self.is_connected = True
logger.info("LaiYu_Liquid 硬件连接成功")
return True
else:
logger.error("LaiYu_Liquid 硬件连接失败")
return False
else:
# 模拟模式
logger.info("LaiYu_Liquid 运行在模拟模式")
self.is_connected = True
return True
except Exception as e:
logger.error(f"LaiYu_Liquid 设置失败: {e}")
return False
async def stop(self):
"""停止设备"""
try:
if self.pipette_controller and hasattr(self.pipette_controller, "disconnect"):
await asyncio.to_thread(self.pipette_controller.disconnect)
if self.xyz_controller and hasattr(self.xyz_controller, "disconnect"):
await asyncio.to_thread(self.xyz_controller.disconnect)
self.is_connected = False
self.is_initialized = False
logger.info("LaiYu_Liquid 已停止")
except Exception as e:
logger.error(f"LaiYu_Liquid 停止失败: {e}")
async def move_to(self, x: float, y: float, z: float) -> bool:
"""移动到指定位置"""
try:
if not self.is_connected:
raise LaiYuLiquidError("设备未连接")
# 模拟移动
await self._ros_node.sleep(0.1) # 模拟移动时间
self.current_position = (x, y, z)
logger.debug(f"移动到位置: ({x}, {y}, {z})")
return True
except Exception as e:
logger.error(f"移动失败: {e}")
return False
async def pick_up_tip(self, tip_rack: str, position: int) -> bool:
"""拾取吸头 - 包含真正的Z轴下降控制"""
try:
# 硬件准备检查
if not self._check_hardware_ready():
return False
if self.tip_attached:
logger.warning("已有吸头附着,无法拾取新吸头")
return False
logger.info(f"开始从 {tip_rack} 位置 {position} 拾取吸头")
# 获取枪头架位置信息
if self.deck is None:
logger.error("工作台未初始化")
return False
tip_position = self.deck.get_position(tip_rack)
if tip_position is None:
logger.error(f"未找到枪头架 {tip_rack} 的位置信息")
return False
# 计算具体枪头位置这里简化处理实际应根据position计算偏移
tip_x, tip_y, tip_z = tip_position
# 验证所有关键位置的安全性
safe_z = tip_z + self.config.tip_approach_height
pickup_z = tip_z - self.config.tip_pickup_force_depth
retract_z = tip_z + self.config.tip_pickup_retract_height
if not (
self._validate_position(tip_x, tip_y, safe_z)
and self._validate_position(tip_x, tip_y, pickup_z)
and self._validate_position(tip_x, tip_y, retract_z)
):
logger.error("枪头拾取位置超出安全范围")
return False
if CONTROLLERS_AVAILABLE and self.xyz_controller:
# 真实硬件控制流程
logger.info("使用真实XYZ控制器进行枪头拾取")
try:
# 1. 移动到枪头上方的安全位置
safe_z = tip_z + self.config.tip_approach_height
logger.info(f"移动到枪头上方安全位置: ({tip_x:.2f}, {tip_y:.2f}, {safe_z:.2f})")
move_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord, tip_x, tip_y, safe_z
)
if not move_success:
logger.error("移动到枪头上方失败")
return False
# 2. Z轴下降到枪头位置
pickup_z = tip_z - self.config.tip_pickup_force_depth
logger.info(f"Z轴下降到枪头拾取位置: {pickup_z:.2f}mm")
z_down_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord, tip_x, tip_y, pickup_z
)
if not z_down_success:
logger.error("Z轴下降到枪头位置失败")
return False
# 3. 等待一小段时间确保枪头牢固附着
await self._ros_node.sleep(0.2)
# 4. Z轴上升到回退高度
retract_z = tip_z + self.config.tip_pickup_retract_height
logger.info(f"Z轴上升到回退高度: {retract_z:.2f}mm")
z_up_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord, tip_x, tip_y, retract_z
)
if not z_up_success:
logger.error("Z轴上升失败")
return False
# 5. 更新当前位置
self.current_position = (tip_x, tip_y, retract_z)
except Exception as move_error:
logger.error(f"枪头拾取过程中发生错误: {move_error}")
# 尝试移动到安全位置
if self.config.emergency_stop_enabled:
await self.emergency_stop()
await self.move_to_safe_position()
return False
else:
# 模拟模式
logger.info("模拟模式:执行枪头拾取动作")
await self._ros_node.sleep(1.0) # 模拟整个拾取过程的时间
self.current_position = (tip_x, tip_y, tip_z + self.config.tip_pickup_retract_height)
# 6. 标记枪头已附着
self.tip_attached = True
logger.info("吸头拾取成功")
return True
except Exception as e:
logger.error(f"拾取吸头失败: {e}")
return False
async def drop_tip(self, location: str = "trash") -> bool:
"""丢弃吸头 - 包含真正的Z轴控制"""
try:
# 硬件准备检查
if not self._check_hardware_ready():
return False
if not self.tip_attached:
logger.warning("没有吸头附着,无需丢弃")
return True
logger.info(f"开始丢弃吸头到 {location}")
# 确定丢弃位置
if location == "trash":
# 使用配置中的垃圾桶位置
drop_x, drop_y, drop_z = self.config.trash_position
else:
# 尝试从deck获取指定位置
if self.deck is None:
logger.error("工作台未初始化")
return False
drop_position = self.deck.get_position(location)
if drop_position is None:
logger.error(f"未找到丢弃位置 {location} 的信息")
return False
drop_x, drop_y, drop_z = drop_position
# 验证丢弃位置的安全性
safe_z = drop_z + self.config.safe_height
drop_height_z = drop_z + self.config.tip_drop_height
if not (
self._validate_position(drop_x, drop_y, safe_z)
and self._validate_position(drop_x, drop_y, drop_height_z)
):
logger.error("枪头丢弃位置超出安全范围")
return False
if CONTROLLERS_AVAILABLE and self.xyz_controller:
# 真实硬件控制流程
logger.info("使用真实XYZ控制器进行枪头丢弃")
try:
# 1. 移动到丢弃位置上方的安全高度
safe_z = drop_z + self.config.tip_drop_height
logger.info(f"移动到丢弃位置上方: ({drop_x:.2f}, {drop_y:.2f}, {safe_z:.2f})")
move_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord, drop_x, drop_y, safe_z
)
if not move_success:
logger.error("移动到丢弃位置上方失败")
return False
# 2. Z轴下降到丢弃高度
logger.info(f"Z轴下降到丢弃高度: {drop_z:.2f}mm")
z_down_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord, drop_x, drop_y, drop_z
)
if not z_down_success:
logger.error("Z轴下降到丢弃位置失败")
return False
# 3. 执行枪头弹出动作(如果有移液器控制器)
if self.pipette_controller:
try:
# 发送弹出枪头命令
await asyncio.to_thread(self.pipette_controller.eject_tip)
logger.info("执行枪头弹出命令")
except Exception as e:
logger.warning(f"枪头弹出命令失败: {e}")
# 4. 等待一小段时间确保枪头完全脱离
await self._ros_node.sleep(0.3)
# 5. Z轴上升到安全高度
logger.info(f"Z轴上升到安全高度: {safe_z:.2f}mm")
z_up_success = await asyncio.to_thread(
self.xyz_controller.move_to_work_coord, drop_x, drop_y, safe_z
)
if not z_up_success:
logger.error("Z轴上升失败")
return False
# 6. 更新当前位置
self.current_position = (drop_x, drop_y, safe_z)
except Exception as drop_error:
logger.error(f"枪头丢弃过程中发生错误: {drop_error}")
# 尝试移动到安全位置
if self.config.emergency_stop_enabled:
await self.emergency_stop()
await self.move_to_safe_position()
return False
else:
# 模拟模式
logger.info("模拟模式:执行枪头丢弃动作")
await self._ros_node.sleep(0.8) # 模拟整个丢弃过程的时间
self.current_position = (drop_x, drop_y, drop_z + self.config.tip_drop_height)
# 7. 标记枪头已脱离,清空体积
self.tip_attached = False
self.current_volume = 0.0
logger.info("吸头丢弃成功")
return True
except Exception as e:
logger.error(f"丢弃吸头失败: {e}")
return False
async def aspirate(self, volume: float, location: str) -> bool:
"""吸取液体"""
try:
if not self.is_connected:
raise LaiYuLiquidError("设备未连接")
if not self.tip_attached:
raise LaiYuLiquidError("没有吸头附着")
if volume <= 0 or volume > self.config.max_volume:
raise LaiYuLiquidError(f"体积超出范围: {volume}")
# 模拟吸取
await self._ros_node.sleep(0.3)
self.current_volume += volume
logger.debug(f"{location} 吸取 {volume} μL")
return True
except Exception as e:
logger.error(f"吸取失败: {e}")
return False
async def dispense(self, volume: float, location: str) -> bool:
"""分配液体"""
try:
if not self.is_connected:
raise LaiYuLiquidError("设备未连接")
if not self.tip_attached:
raise LaiYuLiquidError("没有吸头附着")
if volume <= 0 or volume > self.current_volume:
raise LaiYuLiquidError(f"分配体积无效: {volume}")
# 模拟分配
await self._ros_node.sleep(0.3)
self.current_volume -= volume
logger.debug(f"{location} 分配 {volume} μL")
return True
except Exception as e:
logger.error(f"分配失败: {e}")
return False
class LaiYuLiquid:
"""LaiYu_Liquid 主要接口类"""
def __init__(self, config: Optional[LaiYuLiquidConfig] = None, **kwargs):
# 如果传入了关键字参数,创建配置对象
if kwargs and config is None:
# 从kwargs中提取配置参数
config_params = {}
for key, value in kwargs.items():
if hasattr(LaiYuLiquidConfig, key):
config_params[key] = value
self.config = LaiYuLiquidConfig(**config_params)
else:
self.config = config or LaiYuLiquidConfig()
# 先创建deck然后传递给backend
self.deck = LaiYuLiquidDeck(self.config)
self.backend = LaiYuLiquidBackend(self.config, self.deck)
self.is_setup = False
@property
def current_position(self) -> Tuple[float, float, float]:
"""获取当前位置"""
return self.backend.current_position
@property
def current_volume(self) -> float:
"""获取当前体积"""
return self.backend.current_volume
@property
def is_connected(self) -> bool:
"""获取连接状态"""
return self.backend.is_connected
@property
def is_initialized(self) -> bool:
"""获取初始化状态"""
return self.backend.is_initialized
@property
def tip_attached(self) -> bool:
"""获取吸头附着状态"""
return self.backend.tip_attached
async def setup(self) -> bool:
"""设置液体处理器"""
try:
success = await self.backend.setup()
if success:
self.is_setup = True
logger.info("LaiYu_Liquid 设置完成")
return success
except Exception as e:
logger.error(f"LaiYu_Liquid 设置失败: {e}")
return False
async def stop(self):
"""停止液体处理器"""
await self.backend.stop()
self.is_setup = False
async def transfer(
self, source: str, target: str, volume: float, tip_rack: str = "tip_rack_1", tip_position: int = 0
) -> bool:
"""液体转移"""
try:
if not self.is_setup:
raise LaiYuLiquidError("设备未设置")
# 获取源和目标位置
source_pos = self.deck.get_position(source)
target_pos = self.deck.get_position(target)
tip_pos = self.deck.get_position(tip_rack)
if not all([source_pos, target_pos, tip_pos]):
raise LaiYuLiquidError("位置信息不完整")
# 执行转移步骤
steps = [
("移动到吸头架", self.backend.move_to(*tip_pos)),
("拾取吸头", self.backend.pick_up_tip(tip_rack, tip_position)),
("移动到源位置", self.backend.move_to(*source_pos)),
("吸取液体", self.backend.aspirate(volume, source)),
("移动到目标位置", self.backend.move_to(*target_pos)),
("分配液体", self.backend.dispense(volume, target)),
("丢弃吸头", self.backend.drop_tip()),
]
for step_name, step_coro in steps:
logger.debug(f"执行步骤: {step_name}")
success = await step_coro
if not success:
raise LaiYuLiquidError(f"步骤失败: {step_name}")
logger.info(f"液体转移完成: {source} -> {target}, {volume} μL")
return True
except Exception as e:
logger.error(f"液体转移失败: {e}")
return False
def add_resource(self, name: str, resource_type: str, position: Tuple[float, float, float]):
"""添加资源到工作台"""
if resource_type == "plate":
resource = Plate(name)
elif resource_type == "tip_rack":
resource = TipRack(name)
else:
resource = Resource(name)
self.deck.add_resource(name, resource, position)
def get_status(self) -> Dict[str, Any]:
"""获取设备状态"""
return {
"connected": self.backend.is_connected,
"setup": self.is_setup,
"current_position": self.backend.current_position,
"tip_attached": self.backend.tip_attached,
"current_volume": self.backend.current_volume,
"resources": self.deck.list_resources(),
}
def create_quick_setup() -> LaiYuLiquidDeck:
"""
创建快速设置的LaiYu液体处理工作站
Returns:
LaiYuLiquidDeck: 配置好的工作台实例
"""
# 创建默认配置
config = LaiYuLiquidConfig()
# 创建工作台
deck = LaiYuLiquidDeck(config)
# 导入资源创建函数
try:
from .laiyu_liquid_res import (
create_tip_rack_1000ul,
create_tip_rack_200ul,
create_96_well_plate,
create_waste_container,
)
# 添加基本资源
tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000")
tip_rack_200 = create_tip_rack_200ul("tip_rack_200")
plate_96 = create_96_well_plate("plate_96")
waste = create_waste_container("waste")
# 添加到工作台
deck.add_resource("tip_rack_1000", tip_rack_1000, (50, 50, 0))
deck.add_resource("tip_rack_200", tip_rack_200, (150, 50, 0))
deck.add_resource("plate_96", plate_96, (250, 50, 0))
deck.add_resource("waste", waste, (50, 150, 0))
except ImportError:
# 如果资源模块不可用,创建空的工作台
logger.warning("资源模块不可用,创建空的工作台")
return deck
__all__ = [
"LaiYuLiquid",
"LaiYuLiquidBackend",
"LaiYuLiquidConfig",
"LaiYuLiquidDeck",
"LaiYuLiquidContainer",
"LaiYuLiquidTipRack",
"LaiYuLiquidError",
"create_quick_setup",
"get_module_info",
]

View File

@@ -1,954 +0,0 @@
"""
LaiYu_Liquid 资源定义模块
该模块提供了 LaiYu_Liquid 工作站专用的资源定义函数,包括:
- 各种规格的枪头架
- 不同类型的板和容器
- 特殊功能位置
- 资源创建的便捷函数
所有资源都基于 deck.json 中的配置参数创建。
"""
import json
import os
from typing import Dict, List, Optional, Tuple, Any
from pathlib import Path
# PyLabRobot 资源导入
try:
from pylabrobot.resources import (
Resource, Deck, Plate, TipRack, Container, Tip,
Coordinate
)
from pylabrobot.resources.tip_rack import TipSpot
from pylabrobot.resources.well import Well as PlateWell
PYLABROBOT_AVAILABLE = True
except ImportError:
# 如果 PyLabRobot 不可用,创建模拟类
PYLABROBOT_AVAILABLE = False
class Resource:
def __init__(self, name: str):
self.name = name
class Deck(Resource):
pass
class Plate(Resource):
pass
class TipRack(Resource):
pass
class Container(Resource):
pass
class Tip(Resource):
pass
class TipSpot(Resource):
def __init__(self, name: str, **kwargs):
super().__init__(name)
# 忽略其他参数
class PlateWell(Resource):
pass
class Coordinate:
def __init__(self, x: float, y: float, z: float):
self.x = x
self.y = y
self.z = z
# 本地导入
from .laiyu_liquid_main import LaiYuLiquidDeck, LaiYuLiquidContainer, LaiYuLiquidTipRack
def load_deck_config() -> Dict[str, Any]:
"""
加载工作台配置文件
Returns:
Dict[str, Any]: 配置字典
"""
# 优先使用最新的deckconfig.json文件
config_path = Path(__file__).parent / "controllers" / "deckconfig.json"
# 如果最新配置文件不存在,回退到旧配置文件
if not config_path.exists():
config_path = Path(__file__).parent / "config" / "deck.json"
try:
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
# 如果找不到配置文件,返回默认配置
return {
"name": "LaiYu_Liquid_Deck",
"size_x": 340.0,
"size_y": 250.0,
"size_z": 160.0
}
# 加载配置
DECK_CONFIG = load_deck_config()
class LaiYuTipRack1000(LaiYuLiquidTipRack):
"""1000μL 枪头架"""
def __init__(self, name: str):
"""
初始化1000μL枪头架
Args:
name: 枪头架名称
"""
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=30.0,
tip_count=96,
tip_volume=1000.0
)
# 创建枪头位置
self._create_tip_spots(
tip_count=96,
tip_spacing=9.0,
tip_type="1000ul"
)
def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str):
"""
创建枪头位置 - 从配置文件中读取绝对坐标
Args:
tip_count: 枪头数量
tip_spacing: 枪头间距
tip_type: 枪头类型
"""
# 从配置文件中获取枪头架的孔位信息
config = DECK_CONFIG
tip_module = None
# 查找枪头架模块
for module in config.get("children", []):
if module.get("type") == "tip_rack":
tip_module = module
break
if not tip_module:
# 如果配置文件中没有找到,使用默认的相对坐标计算
rows = 8
cols = 12
for row in range(rows):
for col in range(cols):
spot_name = f"{chr(65 + row)}{col + 1:02d}"
x = col * tip_spacing + tip_spacing / 2
y = row * tip_spacing + tip_spacing / 2
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
if PYLABROBOT_AVAILABLE:
# PyLabRobot的Tip需要特定参数
tip = Tip(
has_filter=False,
total_tip_length=95.0, # 1000ul枪头长度
maximal_volume=1000.0, # 最大体积
fitting_depth=8.0 # 安装深度
)
else:
# 模拟类只需要name
tip = Tip(name=f"tip_{spot_name}")
# 创建枪头位置
if PYLABROBOT_AVAILABLE:
# PyLabRobot的TipSpot需要特定参数
tip_spot = TipSpot(
name=spot_name,
size_x=9.0, # 枪头位置宽度
size_y=9.0, # 枪头位置深度
size_z=95.0, # 枪头位置高度
make_tip=lambda: tip # 创建枪头的函数
)
else:
# 模拟类只需要name
tip_spot = TipSpot(name=spot_name)
# 将吸头位置分配到吸头架
self.assign_child_resource(
tip_spot,
location=Coordinate(x, y, 0)
)
return
# 使用配置文件中的绝对坐标
module_position = tip_module.get("position", {"x": 0, "y": 0, "z": 0})
for well_config in tip_module.get("wells", []):
spot_name = well_config["id"]
well_pos = well_config["position"]
# 计算相对于模块的坐标(绝对坐标减去模块位置)
relative_x = well_pos["x"] - module_position["x"]
relative_y = well_pos["y"] - module_position["y"]
relative_z = well_pos["z"] - module_position["z"]
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
if PYLABROBOT_AVAILABLE:
# PyLabRobot的Tip需要特定参数
tip = Tip(
has_filter=False,
total_tip_length=95.0, # 1000ul枪头长度
maximal_volume=1000.0, # 最大体积
fitting_depth=8.0 # 安装深度
)
else:
# 模拟类只需要name
tip = Tip(name=f"tip_{spot_name}")
# 创建枪头位置
if PYLABROBOT_AVAILABLE:
# PyLabRobot的TipSpot需要特定参数
tip_spot = TipSpot(
name=spot_name,
size_x=well_config.get("diameter", 9.0), # 使用配置中的直径
size_y=well_config.get("diameter", 9.0),
size_z=well_config.get("depth", 95.0), # 使用配置中的深度
make_tip=lambda: tip # 创建枪头的函数
)
else:
# 模拟类只需要name
tip_spot = TipSpot(name=spot_name)
# 将吸头位置分配到吸头架
self.assign_child_resource(
tip_spot,
location=Coordinate(relative_x, relative_y, relative_z)
)
# 注意在PyLabRobot中Tip不是Resource不需要分配给TipSpot
# TipSpot的make_tip函数会在需要时创建Tip
class LaiYuTipRack200(LaiYuLiquidTipRack):
"""200μL 枪头架"""
def __init__(self, name: str):
"""
初始化200μL枪头架
Args:
name: 枪头架名称
"""
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=30.0,
tip_count=96,
tip_volume=200.0
)
# 创建枪头位置
self._create_tip_spots(
tip_count=96,
tip_spacing=9.0,
tip_type="200ul"
)
def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str):
"""
创建枪头位置
Args:
tip_count: 枪头数量
tip_spacing: 枪头间距
tip_type: 枪头类型
"""
rows = 8
cols = 12
for row in range(rows):
for col in range(cols):
spot_name = f"{chr(65 + row)}{col + 1:02d}"
x = col * tip_spacing + tip_spacing / 2
y = row * tip_spacing + tip_spacing / 2
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
if PYLABROBOT_AVAILABLE:
# PyLabRobot的Tip需要特定参数
tip = Tip(
has_filter=False,
total_tip_length=72.0, # 200ul枪头长度
maximal_volume=200.0, # 最大体积
fitting_depth=8.0 # 安装深度
)
else:
# 模拟类只需要name
tip = Tip(name=f"tip_{spot_name}")
# 创建枪头位置
if PYLABROBOT_AVAILABLE:
# PyLabRobot的TipSpot需要特定参数
tip_spot = TipSpot(
name=spot_name,
size_x=9.0, # 枪头位置宽度
size_y=9.0, # 枪头位置深度
size_z=72.0, # 枪头位置高度
make_tip=lambda: tip # 创建枪头的函数
)
else:
# 模拟类只需要name
tip_spot = TipSpot(name=spot_name)
# 将吸头位置分配到吸头架
self.assign_child_resource(
tip_spot,
location=Coordinate(x, y, 0)
)
# 注意在PyLabRobot中Tip不是Resource不需要分配给TipSpot
# TipSpot的make_tip函数会在需要时创建Tip
class LaiYu96WellPlate(LaiYuLiquidContainer):
"""96孔板"""
def __init__(self, name: str, lid_height: float = 0.0):
"""
初始化96孔板
Args:
name: 板名称
lid_height: 盖子高度
"""
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=14.22,
container_type="96_well_plate",
volume=0.0,
max_volume=200.0,
lid_height=lid_height
)
# 创建孔位
self._create_wells(
well_count=96,
well_volume=200.0,
well_spacing=9.0
)
def get_size_z(self) -> float:
"""获取孔位深度"""
return 10.0 # 96孔板孔位深度
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
"""
创建孔位 - 从配置文件中读取绝对坐标
Args:
well_count: 孔位数量
well_volume: 孔位体积
well_spacing: 孔位间距
"""
# 从配置文件中获取96孔板的孔位信息
config = DECK_CONFIG
plate_module = None
# 查找96孔板模块
for module in config.get("children", []):
if module.get("type") == "96_well_plate":
plate_module = module
break
if not plate_module:
# 如果配置文件中没有找到,使用默认的相对坐标计算
rows = 8
cols = 12
for row in range(rows):
for col in range(cols):
well_name = f"{chr(65 + row)}{col + 1:02d}"
x = col * well_spacing + well_spacing / 2
y = row * well_spacing + well_spacing / 2
# 创建孔位
well = PlateWell(
name=well_name,
size_x=well_spacing * 0.8,
size_y=well_spacing * 0.8,
size_z=self.get_size_z(),
max_volume=well_volume
)
# 添加到板
self.assign_child_resource(
well,
location=Coordinate(x, y, 0)
)
return
# 使用配置文件中的绝对坐标
module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0})
for well_config in plate_module.get("wells", []):
well_name = well_config["id"]
well_pos = well_config["position"]
# 计算相对于模块的坐标(绝对坐标减去模块位置)
relative_x = well_pos["x"] - module_position["x"]
relative_y = well_pos["y"] - module_position["y"]
relative_z = well_pos["z"] - module_position["z"]
# 创建孔位
well = PlateWell(
name=well_name,
size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径
size_y=well_config.get("diameter", 8.2) * 0.8,
size_z=well_config.get("depth", self.get_size_z()),
max_volume=well_config.get("volume", well_volume)
)
# 添加到板
self.assign_child_resource(
well,
location=Coordinate(relative_x, relative_y, relative_z)
)
class LaiYuDeepWellPlate(LaiYuLiquidContainer):
"""深孔板"""
def __init__(self, name: str, lid_height: float = 0.0):
"""
初始化深孔板
Args:
name: 板名称
lid_height: 盖子高度
"""
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=41.3,
container_type="deep_well_plate",
volume=0.0,
max_volume=2000.0,
lid_height=lid_height
)
# 创建孔位
self._create_wells(
well_count=96,
well_volume=2000.0,
well_spacing=9.0
)
def get_size_z(self) -> float:
"""获取孔位深度"""
return 35.0 # 深孔板孔位深度
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
"""
创建孔位 - 从配置文件中读取绝对坐标
Args:
well_count: 孔位数量
well_volume: 孔位体积
well_spacing: 孔位间距
"""
# 从配置文件中获取深孔板的孔位信息
config = DECK_CONFIG
plate_module = None
# 查找深孔板模块通常是第二个96孔板模块
plate_modules = []
for module in config.get("children", []):
if module.get("type") == "96_well_plate":
plate_modules.append(module)
# 如果有多个96孔板模块选择第二个作为深孔板
if len(plate_modules) > 1:
plate_module = plate_modules[1]
elif len(plate_modules) == 1:
plate_module = plate_modules[0]
if not plate_module:
# 如果配置文件中没有找到,使用默认的相对坐标计算
rows = 8
cols = 12
for row in range(rows):
for col in range(cols):
well_name = f"{chr(65 + row)}{col + 1:02d}"
x = col * well_spacing + well_spacing / 2
y = row * well_spacing + well_spacing / 2
# 创建孔位
well = PlateWell(
name=well_name,
size_x=well_spacing * 0.8,
size_y=well_spacing * 0.8,
size_z=self.get_size_z(),
max_volume=well_volume
)
# 添加到板
self.assign_child_resource(
well,
location=Coordinate(x, y, 0)
)
return
# 使用配置文件中的绝对坐标
module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0})
for well_config in plate_module.get("wells", []):
well_name = well_config["id"]
well_pos = well_config["position"]
# 计算相对于模块的坐标(绝对坐标减去模块位置)
relative_x = well_pos["x"] - module_position["x"]
relative_y = well_pos["y"] - module_position["y"]
relative_z = well_pos["z"] - module_position["z"]
# 创建孔位
well = PlateWell(
name=well_name,
size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径
size_y=well_config.get("diameter", 8.2) * 0.8,
size_z=well_config.get("depth", self.get_size_z()),
max_volume=well_config.get("volume", well_volume)
)
# 添加到板
self.assign_child_resource(
well,
location=Coordinate(relative_x, relative_y, relative_z)
)
class LaiYuWasteContainer(Container):
"""废液容器"""
def __init__(self, name: str):
"""
初始化废液容器
Args:
name: 容器名称
"""
super().__init__(
name=name,
size_x=100.0,
size_y=100.0,
size_z=50.0,
max_volume=5000.0
)
class LaiYuWashContainer(Container):
"""清洗容器"""
def __init__(self, name: str):
"""
初始化清洗容器
Args:
name: 容器名称
"""
super().__init__(
name=name,
size_x=100.0,
size_y=100.0,
size_z=50.0,
max_volume=5000.0
)
class LaiYuReagentContainer(Container):
"""试剂容器"""
def __init__(self, name: str):
"""
初始化试剂容器
Args:
name: 容器名称
"""
super().__init__(
name=name,
size_x=50.0,
size_y=50.0,
size_z=100.0,
max_volume=2000.0
)
class LaiYu8TubeRack(LaiYuLiquidContainer):
"""8管试管架"""
def __init__(self, name: str):
"""
初始化8管试管架
Args:
name: 试管架名称
"""
super().__init__(
name=name,
size_x=151.0,
size_y=75.0,
size_z=75.0,
container_type="tube_rack",
volume=0.0,
max_volume=77000.0
)
# 创建孔位
self._create_wells(
well_count=8,
well_volume=77000.0,
well_spacing=35.0
)
def get_size_z(self) -> float:
"""获取孔位深度"""
return 117.0 # 试管深度
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
"""
创建孔位 - 从配置文件中读取绝对坐标
Args:
well_count: 孔位数量
well_volume: 孔位体积
well_spacing: 孔位间距
"""
# 从配置文件中获取8管试管架的孔位信息
config = DECK_CONFIG
tube_module = None
# 查找8管试管架模块
for module in config.get("children", []):
if module.get("type") == "tube_rack":
tube_module = module
break
if not tube_module:
# 如果配置文件中没有找到,使用默认的相对坐标计算
rows = 2
cols = 4
for row in range(rows):
for col in range(cols):
well_name = f"{chr(65 + row)}{col + 1}"
x = col * well_spacing + well_spacing / 2
y = row * well_spacing + well_spacing / 2
# 创建孔位
well = PlateWell(
name=well_name,
size_x=29.0,
size_y=29.0,
size_z=self.get_size_z(),
max_volume=well_volume
)
# 添加到试管架
self.assign_child_resource(
well,
location=Coordinate(x, y, 0)
)
return
# 使用配置文件中的绝对坐标
module_position = tube_module.get("position", {"x": 0, "y": 0, "z": 0})
for well_config in tube_module.get("wells", []):
well_name = well_config["id"]
well_pos = well_config["position"]
# 计算相对于模块的坐标(绝对坐标减去模块位置)
relative_x = well_pos["x"] - module_position["x"]
relative_y = well_pos["y"] - module_position["y"]
relative_z = well_pos["z"] - module_position["z"]
# 创建孔位
well = PlateWell(
name=well_name,
size_x=well_config.get("diameter", 29.0),
size_y=well_config.get("diameter", 29.0),
size_z=well_config.get("depth", self.get_size_z()),
max_volume=well_config.get("volume", well_volume)
)
# 添加到试管架
self.assign_child_resource(
well,
location=Coordinate(relative_x, relative_y, relative_z)
)
class LaiYuTipDisposal(Resource):
"""枪头废料位置"""
def __init__(self, name: str):
"""
初始化枪头废料位置
Args:
name: 位置名称
"""
super().__init__(
name=name,
size_x=100.0,
size_y=100.0,
size_z=50.0
)
class LaiYuMaintenancePosition(Resource):
"""维护位置"""
def __init__(self, name: str):
"""
初始化维护位置
Args:
name: 位置名称
"""
super().__init__(
name=name,
size_x=50.0,
size_y=50.0,
size_z=100.0
)
# 资源创建函数
def create_tip_rack_1000ul(name: str = "tip_rack_1000ul") -> LaiYuTipRack1000:
"""
创建1000μL枪头架
Args:
name: 枪头架名称
Returns:
LaiYuTipRack1000: 1000μL枪头架实例
"""
return LaiYuTipRack1000(name)
def create_tip_rack_200ul(name: str = "tip_rack_200ul") -> LaiYuTipRack200:
"""
创建200μL枪头架
Args:
name: 枪头架名称
Returns:
LaiYuTipRack200: 200μL枪头架实例
"""
return LaiYuTipRack200(name)
def create_96_well_plate(name: str = "96_well_plate", lid_height: float = 0.0) -> LaiYu96WellPlate:
"""
创建96孔板
Args:
name: 板名称
lid_height: 盖子高度
Returns:
LaiYu96WellPlate: 96孔板实例
"""
return LaiYu96WellPlate(name, lid_height)
def create_deep_well_plate(name: str = "deep_well_plate", lid_height: float = 0.0) -> LaiYuDeepWellPlate:
"""
创建深孔板
Args:
name: 板名称
lid_height: 盖子高度
Returns:
LaiYuDeepWellPlate: 深孔板实例
"""
return LaiYuDeepWellPlate(name, lid_height)
def create_8_tube_rack(name: str = "8_tube_rack") -> LaiYu8TubeRack:
"""
创建8管试管架
Args:
name: 试管架名称
Returns:
LaiYu8TubeRack: 8管试管架实例
"""
return LaiYu8TubeRack(name)
def create_waste_container(name: str = "waste_container") -> LaiYuWasteContainer:
"""
创建废液容器
Args:
name: 容器名称
Returns:
LaiYuWasteContainer: 废液容器实例
"""
return LaiYuWasteContainer(name)
def create_wash_container(name: str = "wash_container") -> LaiYuWashContainer:
"""
创建清洗容器
Args:
name: 容器名称
Returns:
LaiYuWashContainer: 清洗容器实例
"""
return LaiYuWashContainer(name)
def create_reagent_container(name: str = "reagent_container") -> LaiYuReagentContainer:
"""
创建试剂容器
Args:
name: 容器名称
Returns:
LaiYuReagentContainer: 试剂容器实例
"""
return LaiYuReagentContainer(name)
def create_tip_disposal(name: str = "tip_disposal") -> LaiYuTipDisposal:
"""
创建枪头废料位置
Args:
name: 位置名称
Returns:
LaiYuTipDisposal: 枪头废料位置实例
"""
return LaiYuTipDisposal(name)
def create_maintenance_position(name: str = "maintenance_position") -> LaiYuMaintenancePosition:
"""
创建维护位置
Args:
name: 位置名称
Returns:
LaiYuMaintenancePosition: 维护位置实例
"""
return LaiYuMaintenancePosition(name)
def create_standard_deck() -> LaiYuLiquidDeck:
"""
创建标准工作台配置
Returns:
LaiYuLiquidDeck: 配置好的工作台实例
"""
# 从配置文件创建工作台
deck = LaiYuLiquidDeck(config=DECK_CONFIG)
return deck
def get_resource_by_name(deck: LaiYuLiquidDeck, name: str) -> Optional[Resource]:
"""
根据名称获取资源
Args:
deck: 工作台实例
name: 资源名称
Returns:
Optional[Resource]: 找到的资源如果不存在则返回None
"""
for child in deck.children:
if child.name == name:
return child
return None
def get_resources_by_type(deck: LaiYuLiquidDeck, resource_type: type) -> List[Resource]:
"""
根据类型获取资源列表
Args:
deck: 工作台实例
resource_type: 资源类型
Returns:
List[Resource]: 匹配类型的资源列表
"""
return [child for child in deck.children if isinstance(child, resource_type)]
def list_all_resources(deck: LaiYuLiquidDeck) -> Dict[str, List[str]]:
"""
列出所有资源
Args:
deck: 工作台实例
Returns:
Dict[str, List[str]]: 按类型分组的资源名称字典
"""
resources = {
"tip_racks": [],
"plates": [],
"containers": [],
"positions": []
}
for child in deck.children:
if isinstance(child, (LaiYuTipRack1000, LaiYuTipRack200)):
resources["tip_racks"].append(child.name)
elif isinstance(child, (LaiYu96WellPlate, LaiYuDeepWellPlate)):
resources["plates"].append(child.name)
elif isinstance(child, (LaiYuWasteContainer, LaiYuWashContainer, LaiYuReagentContainer)):
resources["containers"].append(child.name)
elif isinstance(child, (LaiYuTipDisposal, LaiYuMaintenancePosition)):
resources["positions"].append(child.name)
return resources
# 导出的类别名(向后兼容)
TipRack1000ul = LaiYuTipRack1000
TipRack200ul = LaiYuTipRack200
Plate96Well = LaiYu96WellPlate
Plate96DeepWell = LaiYuDeepWellPlate
TubeRack8 = LaiYu8TubeRack
WasteContainer = LaiYuWasteContainer
WashContainer = LaiYuWashContainer
ReagentContainer = LaiYuReagentContainer
TipDisposal = LaiYuTipDisposal
MaintenancePosition = LaiYuMaintenancePosition

View File

@@ -1,69 +0,0 @@
# 更新日志
本文档记录了 LaiYu_Liquid 模块的所有重要变更。
## [1.0.0] - 2024-01-XX
### 新增功能
- ✅ 完整的液体处理工作站集成
- ✅ RS485 通信协议支持
- ✅ SOPA 气动式移液器驱动
- ✅ XYZ 三轴步进电机控制
- ✅ PyLabRobot 兼容后端
- ✅ 标准化资源管理系统
- ✅ 96孔板、离心管架、枪头架支持
- ✅ RViz 可视化后端
- ✅ 完整的配置管理系统
- ✅ 抽象协议实现
- ✅ 生产级错误处理和日志记录
### 技术特性
- **硬件支持**: SOPA移液器 + XYZ三轴运动平台
- **通信协议**: RS485总线波特率115200
- **坐标系统**: 机械坐标与工作坐标自动转换
- **安全机制**: 限位保护、紧急停止、错误恢复
- **兼容性**: 完全兼容 PyLabRobot 框架
### 文件结构
```
LaiYu_Liquid/
├── core/
│ └── LaiYu_Liquid.py # 主模块文件
├── __init__.py # 模块初始化
├── abstract_protocol.py # 抽象协议
├── laiyu_liquid_res.py # 资源管理
├── rviz_backend.py # RViz后端
├── backend/ # 后端驱动
├── config/ # 配置文件
├── controllers/ # 控制器
├── docs/ # 技术文档
└── drivers/ # 底层驱动
```
### 已知问题
-
### 依赖要求
- Python 3.8+
- PyLabRobot
- pyserial
- asyncio
---
## 版本说明
### 版本号格式
采用语义化版本控制 (Semantic Versioning): `MAJOR.MINOR.PATCH`
- **MAJOR**: 不兼容的API变更
- **MINOR**: 向后兼容的功能新增
- **PATCH**: 向后兼容的问题修复
### 变更类型
- **新增功能**: 新的功能特性
- **变更**: 现有功能的变更
- **弃用**: 即将移除的功能
- **移除**: 已移除的功能
- **修复**: 问题修复
- **安全**: 安全相关的修复

View File

@@ -1,267 +0,0 @@
# SOPA气动式移液器RS485控制指令合集
## 1. RS485通信基本配置
### 1.1 支持的设备型号
- **仅SC-STxxx-00-13支持RS485通信**
- 其他型号主要使用CAN通信
### 1.2 通信参数
- **波特率**: 9600, 115200默认值
- **地址范围**: 1~254个设备255为广播地址
- **通信接口**: RS485差分信号
### 1.3 引脚分配10位LIF连接器
- **引脚7**: RS485+ (RS485通信正极)
- **引脚8**: RS485- (RS485通信负极)
## 2. RS485通信协议格式
### 2.1 发送数据格式
```
头码 | 地址 | 命令/数据 | 尾码 | 校验和
```
### 2.2 从机回应格式
```
头码 | 地址 | 数据固定9字节 | 尾码 | 校验和
```
### 2.3 格式详细说明
- **头码**:
- 终端调试: '/' (0x2F)
- OEM通信: '[' (0x5B)
- **地址**: 设备节点地址1~254多字节ASCII注意地址不可为476991
- **命令/数据**: ASCII格式的命令字符串
- **尾码**: 'E' (0x45)
- **校验和**: 以上数据的累加值1字节
## 3. 初始化和基本控制指令
### 3.1 初始化指令
```bash
# 初始化活塞驱动机构
HE
# 示例OEM通信
# 主机发送: 5B 32 48 45 1A
# 从机回应开始: 2F 02 06 0A 30 00 00 00 00 00 00 45 B6
# 从机回应完成: 2F 02 06 00 30 00 00 00 00 00 00 45 AC
```
### 3.2 枪头操作指令
```bash
# 顶出枪头
RE
# 枪头检测状态报告
Q28 # 返回枪头存在状态0=不存在1=存在)
```
## 4. 移液控制指令
### 4.1 位置控制指令
```bash
# 绝对位置移动(微升)
A[n]E
# 示例移动到位置0
A0E
# 相对抽吸(向上移动)
P[n]E
# 示例抽吸200微升
P200E
# 相对分配(向下移动)
D[n]E
# 示例分配200微升
D200E
```
### 4.2 速度设置指令
```bash
# 设置最高速度0.1ul/秒为单位)
s[n]E
# 示例设置最高速度为2000200ul/秒)
s2000E
# 设置启动速度
b[n]E
# 示例设置启动速度为10010ul/秒)
b100E
# 设置断流速度
c[n]E
# 示例设置断流速度为10010ul/秒)
c100E
# 设置加速度
a[n]E
# 示例设置加速度为30000
a30000E
```
## 5. 液体检测和安全控制指令
### 5.1 吸排液检测控制
```bash
# 开启吸排液检测
f1E # 开启
f0E # 关闭
# 设置空吸门限
$[n]E
# 示例设置空吸门限为4
$4E
# 设置泡沫门限
![n]E
# 示例设置泡沫门限为20
!20E
# 设置堵塞门限
%[n]E
# 示例设置堵塞门限为350
%350E
```
### 5.2 液位检测指令
```bash
# 压力式液位检测
m0E # 设置为压力探测模式
L[n]E # 执行液位检测,[n]为灵敏度(3~40)
k[n]E # 设置检测速度(100~2000)
# 电容式液位检测
m1E # 设置为电容探测模式
```
## 6. 状态查询和报告指令
### 6.1 基本状态查询
```bash
# 查询固件版本
V
# 查询设备状态
Q[n]
# 常用查询参数:
Q01 # 报告加速度
Q02 # 报告启动速度
Q03 # 报告断流速度
Q06 # 报告最大速度
Q08 # 报告节点地址
Q11 # 报告波特率
Q18 # 报告当前位置
Q28 # 报告枪头存在状态
Q29 # 报告校准系数
Q30 # 报告空吸门限
Q31 # 报告堵针门限
Q32 # 报告泡沫门限
```
## 7. 配置和校准指令
### 7.1 校准参数设置
```bash
# 设置校准系数
j[n]E
# 示例设置校准系数为1.04
j1.04E
# 设置补偿偏差
e[n]E
# 示例设置补偿偏差为2.03
e2.03E
# 设置吸头容量
C[n]E
# 示例设置1000ul吸头
C1000E
```
### 7.2 高级控制参数
```bash
# 设置回吸粘度
][n]E
# 示例设置回吸粘度为30
]30E
# 延时控制
M[n]E
# 示例延时1000毫秒
M1000E
```
## 8. 复合操作指令示例
### 8.1 标准移液操作
```bash
# 完整的200ul移液操作
a30000b200c200s2000P200E
# 解析设置加速度30000 + 启动速度200 + 断流速度200 + 最高速度2000 + 抽吸200ul + 执行
```
### 8.2 带检测的移液操作
```bash
# 带空吸检测的200ul抽吸
a30000b200c200s2000f1P200f0E
# 解析:设置参数 + 开启检测 + 抽吸200ul + 关闭检测 + 执行
```
### 8.3 液面检测操作
```bash
# 压力式液面检测
m0k200L5E
# 解析:压力模式 + 检测速度200 + 灵敏度5 + 执行检测
# 电容式液面检测
m1L3E
# 解析:电容模式 + 灵敏度3 + 执行检测
```
## 9. 错误处理
### 9.1 状态字节说明
- **00h**: 无错误
- **01h**: 上次动作未完成
- **02h**: 设备未初始化
- **03h**: 设备过载
- **04h**: 无效指令
- **05h**: 液位探测故障
- **0Dh**: 空吸
- **0Eh**: 堵针
- **10h**: 泡沫
- **11h**: 吸液超过吸头容量
### 9.2 错误查询
```bash
# 查询当前错误状态
Q # 返回状态字节和错误代码
```
## 10. 通信示例
### 10.1 基本通信流程
1. **执行命令**: 主机发送命令 → 从机确认 → 从机执行 → 从机回应完成
2. **读取数据**: 主机发送查询 → 从机确认 → 从机返回数据
### 10.2 快速指令表
| 操作 | 指令 | 说明 |
|------|------|------|
| 初始化 | `HE` | 初始化设备 |
| 退枪头 | `RE` | 顶出枪头 |
| 吸液200ul | `a30000b200c200s2000P200E` | 基本吸液 |
| 带检测吸液 | `a30000b200c200s2000f1P200f0E` | 开启空吸检测 |
| 吐液200ul | `a300000b500c500s6000D200E` | 基本分配 |
| 压力液面检测 | `m0k200L5E` | pLLD检测 |
| 电容液面检测 | `m1L3E` | cLLD检测 |
## 11. 注意事项
1. **地址限制**: RS485地址不可设为47、69、91
2. **校验和**: 终端调试时不关心校验和OEM通信需要校验
3. **ASCII格式**: 所有命令和参数都使用ASCII字符
4. **执行指令**: 大部分命令需要以'E'结尾才能执行
5. **设备支持**: 只有SC-STxxx-00-13型号支持RS485通信
6. **波特率设置**: 默认115200可设置为9600

View File

@@ -1,162 +0,0 @@
# 步进电机B系列控制指令详解
## 基本通信参数
- **通信方式**: RS485
- **协议**: Modbus
- **波特率**: 115200 (默认)
- **数据位**: 8位
- **停止位**: 1位
- **校验位**: 无
- **默认站号**: 1 (可设置1-254)
## 支持的功能码
- **03H**: 读取寄存器
- **06H**: 写入单个寄存器
- **10H**: 写入多个寄存器
## 寄存器地址表
### 状态监控寄存器 (只读)
| 地址 | 功能码 | 内容 | 说明 |
|------|--------|------|------|
| 00H | 03H | 电机状态 | 0000H-待机/到位, 0001H-运行中, 0002H-碰撞停, 0003H-正光电停, 0004H-反光电停 |
| 01H | 03H | 实际步数高位 | 当前电机位置的高16位 |
| 02H | 03H | 实际步数低位 | 当前电机位置的低16位 |
| 03H | 03H | 实际速度 | 当前转速 (rpm) |
| 05H | 03H | 电流 | 当前工作电流 (mA) |
### 控制寄存器 (读写)
| 地址 | 功能码 | 内容 | 说明 |
|------|--------|------|------|
| 04H | 03H/06H/10H | 急停指令 | 紧急停止控制 |
| 06H | 03H/06H/10H | 失能控制 | 1-使能, 0-失能 |
| 07H | 03H/06H/10H | PWM输出 | 0-1000对应0%-100%占空比 |
| 0EH | 03H/06H/10H | 单圈绝对值归零 | 归零指令 |
| 0FH | 03H/06H/10H | 归零指令 | 定点模式归零速度设置 |
### 位置模式寄存器
| 地址 | 功能码 | 内容 | 说明 |
|------|--------|------|------|
| 10H | 03H/06H/10H | 目标步数高位 | 目标位置高16位 |
| 11H | 03H/06H/10H | 目标步数低位 | 目标位置低16位 |
| 12H | 03H/06H/10H | 保留 | - |
| 13H | 03H/06H/10H | 速度 | 运行速度 (rpm) |
| 14H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
| 15H | 03H/06H/10H | 精度 | 到位精度设置 |
### 速度模式寄存器
| 地址 | 功能码 | 内容 | 说明 |
|------|--------|------|------|
| 60H | 03H/06H/10H | 保留 | - |
| 61H | 03H/06H/10H | 速度 | 正值正转,负值反转 |
| 62H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
### 设备参数寄存器
| 地址 | 功能码 | 内容 | 默认值 | 说明 |
|------|--------|------|--------|------|
| E0H | 03H/06H/10H | 设备地址 | 0001H | Modbus从站地址 |
| E1H | 03H/06H/10H | 堵转电流 | 0BB8H | 堵转检测电流阈值 |
| E2H | 03H/06H/10H | 保留 | 0258H | - |
| E3H | 03H/06H/10H | 每圈步数 | 0640H | 细分设置 |
| E4H | 03H/06H/10H | 限位开关使能 | F000H | 1-使能, 0-禁用 |
| E5H | 03H/06H/10H | 堵转逻辑 | 0000H | 00-断电, 01-对抗 |
| E6H | 03H/06H/10H | 堵转时间 | 0000H | 堵转检测时间(ms) |
| E7H | 03H/06H/10H | 默认速度 | 1388H | 上电默认速度 |
| E8H | 03H/06H/10H | 默认加速度 | EA60H | 上电默认加速度 |
| E9H | 03H/06H/10H | 默认精度 | 0064H | 上电默认精度 |
| EAH | 03H/06H/10H | 波特率高位 | 0001H | 通信波特率设置 |
| EBH | 03H/06H/10H | 波特率低位 | C200H | 115200对应01C200H |
### 版本信息寄存器 (只读)
| 地址 | 功能码 | 内容 | 说明 |
|------|--------|------|------|
| F0H | 03H | 版本号 | 固件版本信息 |
| F1H-F4H | 03H | 型号 | 产品型号信息 |
## 常用控制指令示例
### 读取电机状态
```
发送: 01 03 00 00 00 01 84 0A
接收: 01 03 02 00 01 79 84
说明: 电机状态为0001H (正在运行)
```
### 读取当前位置
```
发送: 01 03 00 01 00 02 95 CB
接收: 01 03 04 00 19 00 00 2B F4
说明: 当前位置为1638400步 (100圈)
```
### 停止电机
```
发送: 01 10 00 04 00 01 02 00 00 A7 D4
接收: 01 10 00 04 00 01 40 08
说明: 急停指令
```
### 位置模式运动
```
发送: 01 10 00 10 00 06 0C 00 19 00 00 00 00 13 88 00 00 00 00 9F FB
接收: 01 10 00 10 00 06 41 CE
说明: 以5000rpm速度运动到1638400步位置
```
### 速度模式 - 正转
```
发送: 01 10 00 60 00 04 08 00 00 13 88 00 FA 00 00 F4 77
接收: 01 10 00 60 00 04 C1 D4
说明: 以5000rpm速度正转
```
### 速度模式 - 反转
```
发送: 01 10 00 60 00 04 08 00 00 EC 78 00 FA 00 00 A0 6D
接收: 01 10 00 60 00 04 C1 D4
说明: 以5000rpm速度反转 (EC78H = -5000)
```
### 设置设备地址
```
发送: 00 06 00 E0 00 02 C9 F1
接收: 00 06 00 E0 00 02 C9 F1
说明: 将设备地址设置为2
```
## 错误码
| 状态码 | 含义 |
|--------|------|
| 0001H | 功能码错误 |
| 0002H | 地址错误 |
| 0003H | 长度错误 |
## CRC校验算法
```c
public static byte[] ModBusCRC(byte[] data, int offset, int cnt) {
int wCrc = 0x0000FFFF;
byte[] CRC = new byte[2];
for (int i = 0; i < cnt; i++) {
wCrc ^= ((data[i + offset]) & 0xFF);
for (int j = 0; j < 8; j++) {
if ((wCrc & 0x00000001) == 1) {
wCrc >>= 1;
wCrc ^= 0x0000A001;
} else {
wCrc >>= 1;
}
}
}
CRC[1] = (byte) ((wCrc & 0x0000FF00) >> 8);
CRC[0] = (byte) (wCrc & 0x000000FF);
return CRC;
}
```
## 注意事项
1. 所有16位数据采用大端序传输
2. 步数计算: 实际步数 = 高位<<16 | 低位
3. 负数使用补码表示
4. PWM输出K脚: 0%开漏, 100%接地, 其他输出1KHz PWM
5. 光电开关需使用NPN开漏型
6. 限位开关: LF正向, LB反向

View File

@@ -1,269 +0,0 @@
# LaiYu_Liquid 液体处理工作站
## 概述
LaiYu_Liquid 是一个完全集成到 UniLabOS 的自动化液体处理工作站,基于 RS485 通信协议,专为精确的液体分配和转移操作而设计。本模块已完成生产环境部署准备,提供完整的硬件控制、资源管理和标准化接口。
## 系统组成
### 硬件组件
- **XYZ三轴运动平台**: 3个RS485步进电机驱动地址X轴=0x01, Y轴=0x02, Z轴=0x03
- **SOPA气动式移液器**: RS485总线控制支持精密液体处理操作
- **通信接口**: RS485转USB模块默认波特率115200
- **机械结构**: 稳固工作台面支持离心管架、96孔板等标准实验耗材
### 软件架构
- **驱动层**: 底层硬件通信驱动支持RS485协议
- **控制层**: 高级控制逻辑和坐标系管理
- **抽象层**: 完全符合UniLabOS标准的液体处理接口
- **资源层**: 标准化的实验器具和耗材管理
## 🎯 生产就绪组件
### ✅ 核心驱动程序 (`drivers/`)
- **`sopa_pipette_driver.py`** - SOPA移液器完整驱动
- 支持液体吸取、分配、检测
- 完整的错误处理和状态管理
- 生产级别的通信协议实现
- **`xyz_stepper_driver.py`** - XYZ三轴步进电机驱动
- 精确的位置控制和运动规划
- 安全限位和错误检测
- 高性能运动控制算法
### ✅ 高级控制器 (`controllers/`)
- **`pipette_controller.py`** - 移液控制器
- 封装高级液体处理功能
- 支持多种液体类型和处理参数
- 智能错误恢复机制
- **`xyz_controller.py`** - XYZ运动控制器
- 坐标系管理和转换
- 运动路径优化
- 安全运动控制
### ✅ UniLabOS集成 (`core/LaiYu_Liquid.py`)
- **完整的液体处理抽象接口**
- **标准化的资源管理系统**
- **与PyLabRobot兼容的后端实现**
- **生产级别的错误处理和日志记录**
### ✅ 资源管理系统
- **`laiyu_liquid_res.py`** - 标准化资源定义
- 96孔板、离心管架、枪头架等标准器具
- 自动化的资源创建和配置函数
- 与工作台布局的完美集成
### ✅ 配置管理 (`config/`)
- **`config/deck.json`** - 工作台布局配置
- 精确的空间定义和槽位管理
- 支持多种实验器具的标准化放置
- 可扩展的配置架构
- **`__init__.py`** - 模块集成和导出
- 完整的API导出和版本管理
- 依赖检查和安装验证
- 专业的模块信息展示
<!-- ### ✅ 可视化支持
- **`rviz_backend.py`** - RViz可视化后端
- 实时运动状态可视化
- 液体处理过程监控
- 与ROS系统的无缝集成 -->
## 🚀 核心功能特性
### 液体处理能力
- **精密体积控制**: 支持1-1000μL精确分配
- **多种液体类型**: 水性、有机溶剂、粘稠液体等
- **智能检测**: 液位检测、气泡检测、堵塞检测
- **自动化流程**: 完整的吸取-转移-分配工作流
### 运动控制系统
- **三轴精密定位**: 微米级精度控制
- **路径优化**: 智能运动规划和碰撞避免
- **安全机制**: 限位保护、紧急停止、错误恢复
- **坐标系管理**: 工作坐标与机械坐标的自动转换
### 资源管理
- **标准化器具**: 支持96孔板、离心管架、枪头架等
- **状态跟踪**: 实时监控液体体积、枪头状态等
- **自动配置**: 基于JSON的灵活配置系统
- **扩展性**: 易于添加新的器具类型
## 📁 目录结构
```
LaiYu_Liquid/
├── __init__.py # 模块初始化和API导出
├── readme.md # 本文档
├── backend/ # 后端驱动模块
│ ├── __init__.py
│ └── laiyu_backend.py # PyLabRobot兼容后端
├── core/ # 核心模块
│ ├── core/
│ │ └── LaiYu_Liquid.py # 主设备类
│ ├── abstract_protocol.py # 抽象协议
│ └── laiyu_liquid_res.py # 设备资源定义
├── config/ # 配置文件目录
│ └── deck.json # 工作台布局配置
├── controllers/ # 高级控制器
│ ├── __init__.py
│ ├── pipette_controller.py # 移液控制器
│ └── xyz_controller.py # XYZ运动控制器
├── docs/ # 技术文档
│ ├── SOPA气动式移液器RS485控制指令.md
│ ├── 步进电机控制指令.md
│ └── hardware/ # 硬件相关文档
├── drivers/ # 底层驱动程序
│ ├── __init__.py
│ ├── sopa_pipette_driver.py # SOPA移液器驱动
│ └── xyz_stepper_driver.py # XYZ步进电机驱动
└── tests/ # 测试文件
```
## 🔧 快速开始
### 1. 安装和验证
```python
# 验证模块安装
from unilabos.devices.laiyu_liquid import (
LaiYuLiquid,
LaiYuLiquidConfig,
create_quick_setup,
print_module_info
)
# 查看模块信息
print_module_info()
# 快速创建默认资源
resources = create_quick_setup()
print(f"已创建 {len(resources)} 个资源")
```
### 2. 基本使用示例
```python
from unilabos.devices.LaiYu_Liquid import (
create_quick_setup,
create_96_well_plate,
create_laiyu_backend
)
# 快速创建默认资源
resources = create_quick_setup()
print(f"创建了以下资源: {list(resources.keys())}")
# 创建96孔板
plate_96 = create_96_well_plate("test_plate")
print(f"96孔板包含 {len(plate_96.children)} 个孔位")
# 创建后端实例用于PyLabRobot集成
backend = create_laiyu_backend("LaiYu_Device")
print(f"后端设备: {backend.name}")
```
### 3. 后端驱动使用
```python
from unilabos.devices.laiyu_liquid.backend import create_laiyu_backend
# 创建后端实例
backend = create_laiyu_backend("LaiYu_Liquid_Station")
# 连接设备
await backend.connect()
# 设备归位
await backend.home_device()
# 获取设备状态
status = await backend.get_status()
print(f"设备状态: {status}")
# 断开连接
await backend.disconnect()
```
### 4. 资源管理示例
```python
from unilabos.devices.LaiYu_Liquid import (
create_centrifuge_tube_rack,
create_tip_rack,
load_deck_config
)
# 加载工作台配置
deck_config = load_deck_config()
print(f"工作台尺寸: {deck_config['size_x']}x{deck_config['size_y']}mm")
# 创建不同类型的资源
tube_rack = create_centrifuge_tube_rack("sample_rack")
tip_rack = create_tip_rack("tip_rack_200ul")
print(f"离心管架: {tube_rack.name}, 容量: {len(tube_rack.children)} 个位置")
print(f"枪头架: {tip_rack.name}, 容量: {len(tip_rack.children)} 个枪头")
```
## 🔍 技术架构
### 坐标系统
- **机械坐标**: 基于步进电机的原始坐标系统
- **工作坐标**: 用户友好的实验室坐标系统
- **自动转换**: 透明的坐标系转换和校准
### 通信协议
- **RS485总线**: 高可靠性工业通信标准
- **Modbus协议**: 标准化的设备通信协议
- **错误检测**: 完整的通信错误检测和恢复
### 安全机制
- **限位保护**: 硬件和软件双重限位保护
- **紧急停止**: 即时停止所有运动和操作
- **状态监控**: 实时设备状态监控和报警
## 🧪 验证和测试
### 功能验证
```python
# 验证模块安装
from unilabos.devices.laiyu_liquid import validate_installation
validate_installation()
# 查看模块信息
from unilabos.devices.laiyu_liquid import print_module_info
print_module_info()
```
### 硬件连接测试
```python
# 测试SOPA移液器连接
from unilabos.devices.laiyu_liquid.drivers import SOPAPipette, SOPAConfig
config = SOPAConfig(port="/dev/cu.usbserial-3130", address=4)
pipette = SOPAPipette(config)
success = pipette.connect()
print(f"SOPA连接状态: {'成功' if success else '失败'}")
```
## 📚 维护和支持
### 日志记录
- **结构化日志**: 使用Python logging模块的专业日志记录
- **错误追踪**: 详细的错误信息和堆栈跟踪
- **性能监控**: 操作时间和性能指标记录
### 配置管理
- **JSON配置**: 灵活的JSON格式配置文件
- **参数验证**: 自动配置参数验证和错误提示
- **热重载**: 支持配置文件的动态重载
### 扩展性
- **模块化设计**: 易于扩展和定制的模块化架构
- **插件接口**: 支持第三方插件和扩展
- **API兼容**: 向后兼容的API设计

View File

@@ -1,30 +0,0 @@
"""
LaiYu_Liquid 驱动程序模块
该模块包含了LaiYu_Liquid液体处理工作站的硬件驱动程序
- SOPA移液器驱动程序
- XYZ步进电机驱动程序
"""
# SOPA移液器驱动程序导入
from .sopa_pipette_driver import SOPAPipette, SOPAConfig, SOPAStatusCode
# XYZ步进电机驱动程序导入
from .xyz_stepper_driver import StepperMotorDriver, XYZStepperController, MotorAxis, MotorStatus
__all__ = [
# SOPA移液器
"SOPAPipette",
"SOPAConfig",
"SOPAStatusCode",
# XYZ步进电机
"StepperMotorDriver",
"XYZStepperController",
"MotorAxis",
"MotorStatus",
]
__version__ = "1.0.0"
__author__ = "LaiYu_Liquid Driver Team"
__description__ = "LaiYu_Liquid 硬件驱动程序集合"

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More