Ready for open source (#47)

* Create app/main API

* create example device

* create ROS backend and example device SDK Wrapper

* Add ROS host and host starting from app.py

* Add gripper device and mock implementation

* add "status_types" & "action_types" to ROS device decorator

* add ActionServer debug example

* [bugfix] complete mock gripper example

* ROS Backend Host for Device action calling and Resource management

* add conda/mamba ENV file

* add host_node communication with app/main.py

* add action message value mappings and converters

* Update ilabos.yaml

* Update issue templates

* example devices.json and resources.json

* Fix Device wrapper to use async property and actions (#7)

* Fix Device wrapper to use async property and actions

* Resolve #1 : support async get methods and actions. Give new examples.

* add both sync/async GRBL controller SDK

* 2 call device actions from appmainpy api to ros hostpy (#8)

* feature: add job

* fix:node start

* feature:add get job status

* fix:get device

* clean

* Resolve #5 device connection diagram and workflow compilation support (#9)

* add syringe pump device and its compilation using device connection diagram

* add RunzeSyringePump real device with ROS2 example

* Prototype machine with 1 pump and 1 CNC

* add ROS2ProtocolNode and related functions

* add ilabos_msgs (to use PumpTransfer action)

* add example device connection graph

* refactor protocol_node code into separate file

* add ROS2SerialNode

* add SerialCommand srv in ilabos_msgs

* add pump_protocol example, and fix bugs

* [fix] serial service: avoid async service deadlock by directly call serial `send_command`

* use SendCmd instead of SingleJointPosition for valve control

* initialize device connection graph when server starts

* Fix #5: async workflow execution (#16)

* add rclpyx and protocol example for async-native workflow

* use async in ROS2ProtocolNode, and host initialization

* add examples of "ros-async" protocol implementation, and `run_in_event_loop` for using native async functions

* use "ros-async" in protocols and device nodes

* fix pump_protocol: push to 0.0 μL

* Envs, docs, and conda recipes (#19)

* update ENV: use python 3.11 and deprecate ros-humble-gazebo-ros

* add ilabos-msgs conda recipe

* Update ilabos.yaml

* fix recipe and env yaml

* Add sphinx docs

* add aichemeco

* add bioyong

* add bioyong

* Support XDL devices & protocols (#20)

* [Feature] support multiple protocols in a single ProtocolNode

* add Junjie's code

* Support "Clean" protocol

* Update Grignard_flow_batchreact_single_pumpvalve.json

* test_grignard_add

* add stir device node and example

* Update device_node.py

add print_publish flag to control the node's log output

* NH4Cl_add

* add "HeaterStirrer" device and "HeatChill" action

* add wait time after each pump action for equilibration

* fix stir

* add Separate protocol

* Refactor Separator device and Stir action

* add rotavap_node

* fix stir

* add chiller node

* Move rinsings into PumpTransfer

* Fix SeparateProtocol under refactored Separator device and Stir action

* Supports automatically add new protocol action_types

* fix PumpTransfer protocol because of rinsing

* Add Rotavap and Chiller devices

* fix SeparateProtocol

* add EvaporateProtocol

* add rotavap devices config

* fix HeaterStirrer and SeparatorController IO

* Fix automatically add new protocol action_types

* Add HeaterStirrer and SeparatorController device config

* fix pump protocols

* Fix Evaporate action

* Update evaporate_protocol.py

* add temp_sensor node and add function remap

* update docs

---------

Co-authored-by: 王俊杰 <1800011822@pku.edu.cn>
Co-authored-by: q434343 <554662886@qq.com>

* fix aichemeco

* [Bugfix] fix Windows conda packaging

* add file upload api

* update dependencies: force to use 3.11 and remove conflict in WIN64 and OSX64

* update dependencies: force to use 3.11 and remove conflict in WIN64 and OSX64

* Create aichemeco_simple.py

* fix

* update

* add aichemeco file

* MQTT [1/2]: action start (#25)

* add mq

* fix

* clean

* add class

* fix excel

* update bioyong

* add api

* fix

---------

Co-authored-by: caok@dp.tech <xiaoyeqiannian@163.com>

* motor & grasp

* Add Grasp motor support and enhance EleGripper class

- Introduced a new motor configuration for Grasp in sjtu.json.
- Updated EleGripper class to inherit from UniversalDriver and added status property.
- Implemented move_and_rotate method for coordinated movement.
- Adjusted threading logic in EleGripper initialization.
- Registered Grasp motor in ROS2 device node configuration.

This commit enhances the motor control capabilities by integrating the Grasp motor and improving the existing EleGripper functionality.

* fix read data lenth

* Update Grasp.py

* MQTT (2/2): publish Device Status, Action Feedback & Results (#27)

* Add bridges in HostNode for device_status publishing

* Add "bridges" selection (fastapi & mqtt) when app start

* add MQ feedback & result publisher, and fix message converter

* fix UUID converting between ROS and MQ

* lint api model.py

* Continuous controllers: PID, MPC, custom controllers etc. (#23)

* add controller config & wrapper

* add controller setup at app.main

* control loop example

* fix com port

* add agv , ur_arm and raman

* MQTT (3/4): Unified Resources and Sync when starting the server (#28)

* update http upload api

* generate uuid when init device

* example resource json

* fix

* add new example full-content json (device, resource, graph)

* fix full-content json and related reading code

* fix

* add json_schema when initialize resources

* fix

* update schema

* refactor heaterstirrer.dalong

* fix

* fix refactor heaterstirrer.dalong

* refactor syringepump.runze: use ml instead of μL

* Update ilabos/ros/host.py

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: 王俊杰 <1800011822@pku.edu.cn>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

* Distributed initialization with self-organizing network (#29)

* add distributed launching option "--without_host"

* fix

---------

Co-authored-by: 王俊杰 <1800011822@pku.edu.cn>

* Refactor Workstation: Add resource service and tracking (#30)

* move ilabos/ros/rpc to ilabos/device_comms/rpc, and merge bioyond/aichemeco files under /devices

* add Resource srv and message_converter

* move graphio to ilabos/resources

* refactor resources type conversion

* add resource clients in device_node

* add mock resources service

* pass Gripper1 resource test

* update http resource services

* add AGV compile function

* add AGV transfer protocol

* update full mock_gripper edit_id example

* update full mock_gripper edit_id example

* get and update resource also in protocol_node

* mock resource update in AichemecoHiwo

* Create HT_hiwo.json

* add children in resources

* bugfixes

* fix rpc

* add Revvity winprep

---------

Co-authored-by: wjjxxx <43375851+wjjxxx@users.noreply.github.com>
Co-authored-by: 3218923350 <105201755+3218923350@users.noreply.github.com>

* Distributed launch (2/2): distributed resource create (#32)

* add resource_add request to host for slave mode

* add AGV

* fix protocol resources

* optimize host callbacks

* bugfixes

* add revvity registry

---------

Co-authored-by: 王俊杰 <1800011822@pku.edu.cn>
Co-authored-by: wjjxxx <43375851+wjjxxx@users.noreply.github.com>

* Refactor Driver Files Structure (#33)

* Integration with pywinauto & recorder
Added execute run and initialize procdure

* 酶标仪状态检测、使用示例,整体流程待测试

* nivo ready version

* Add HPLC driver and example script

- Introduced HPLCDriver class for managing HPLC device status and operations.
- Implemented device status monitoring and command execution via ROS2 actions.
- Added example script (hplc.py) demonstrating how to run commands on the HPLC device.
- Created PlayerUtil and UniversalDriver classes for shared functionality across devices.
- Refactored NivoDriver to utilize the new UniversalDriver structure.
- Enhanced error handling and process management in the NivoDriver.

* 修复start的错误定位

* hplc tested ok

* relative path to build msgs

* template_driver & jiageng devices

* fetch correct status type and action type

* fix mtype fetch

* gpc bus integration

* ilab build

* remove chs

* recipe rename

* modbus update 1

* json available

* hplc & modbus rewrite

* Update AgilentHPLC.py

hplc datafile reader

* move ilabos/ros/rpc to ilabos/device_comms/rpc, and merge bioyond/aichemeco files under /devices

* modbus分设备

* gpc

* gpc 2

* fix address

* default register node

* fix MainScreenGPC

* add Resource srv and message_converter

* move graphio to ilabos/resources

* refactor resources type conversion

* add resource clients in device_node

* add mock resources service

* pass Gripper1 resource test

* update http resource services

* add AGV compile function

* add AGV transfer protocol

* update recipe.yaml

* update full mock_gripper edit_id example

* update full mock_gripper edit_id example

* get and update resource also in protocol_node

* mock resource update in AichemecoHiwo

* feat: add other jiageng PLC device code

* ilabos compile

* correct format

* correct recipe format

* correct setup.py format

* remove unnecessary files

* remove unnecessary files

* Create HT_hiwo.json

* add children in resources

* hplc support sample_id

* correct hplc sample_id

* correct hplc sample_id

* hplc upload

* fix type hint

* oss upload tested ver

* recipe yaml fix for linux

* update installation yaml

* refactor: moved all driver files according to its feat

* merge main to dev

---------

Co-authored-by: 王俊杰 <2201110460@stu.pku.edu.cn>
Co-authored-by: Junhan Chang <changjh@pku.edu.cn>
Co-authored-by: jiawei <miaojiawei@dp.tech>

* add: NMR LH and RU device control (#34)

* Add Registry for device drivers and Support GraphML (#35)

* read chemputer graphml

* read graphml in app/main

* add devices in ros/devices

* add schema for devices

* read registry directory and initialize when entry from main

* Delete devices.py

* Update add_protocol.md

* delete unecessary files

* feat: 2278 devices registry yaml (#36)

* read chemputer graphml

* read graphml in app/main

* add devices in ros/devices

* add schema for devices

* read registry directory and initialize when entry from main

* Delete devices.py

* add: NMR LH and RU device control

* fix: modify jiageng devices registry

---------

Co-authored-by: Junhan Chang <changjh@pku.edu.cn>

* Device/Resource Registry and GraphML support (#37)

* add resource type conversion to PLR

* add resource registry and test

* add docs

* fix registry

* add solenoid_valve_mock, its registry and test

* fix registry for directly using examples

* add EvacuateAndRefillProtocol and testcases

* allow function sequence call in ACTION

* add read & write & extra_info for hardware_interface

* Update device_node.py

* add solenoid valve

* add doc developer guide yaml

* fixes for starting IK station

* add graphml grouping parser

* fix graphml grouping parser

* add communication edge parser

* fix io solenoid valve

* Update .gitignore

* Update plates.yaml

---------

Co-authored-by: ColumbiaCC <2100011801@stu.pku.edu.cn>

* Uni-Lab Doc v0.2 (#39)

* add Uni-Lab docs

* change doc name

* Dev (#41)

* Integration with pywinauto & recorder
Added execute run and initialize procdure

* 酶标仪状态检测、使用示例,整体流程待测试

* nivo ready version

* Add HPLC driver and example script

- Introduced HPLCDriver class for managing HPLC device status and operations.
- Implemented device status monitoring and command execution via ROS2 actions.
- Added example script (hplc.py) demonstrating how to run commands on the HPLC device.
- Created PlayerUtil and UniversalDriver classes for shared functionality across devices.
- Refactored NivoDriver to utilize the new UniversalDriver structure.
- Enhanced error handling and process management in the NivoDriver.

* 修复start的错误定位

* hplc tested ok

* relative path to build msgs

* template_driver & jiageng devices

* fetch correct status type and action type

* fix mtype fetch

* gpc bus integration

* ilab build

* remove chs

* recipe rename

* modbus update 1

* json available

* hplc & modbus rewrite

* Update AgilentHPLC.py

hplc datafile reader

* move ilabos/ros/rpc to ilabos/device_comms/rpc, and merge bioyond/aichemeco files under /devices

* modbus分设备

* gpc

* gpc 2

* fix address

* default register node

* fix MainScreenGPC

* add Resource srv and message_converter

* move graphio to ilabos/resources

* refactor resources type conversion

* add resource clients in device_node

* add mock resources service

* pass Gripper1 resource test

* update http resource services

* add AGV compile function

* add AGV transfer protocol

* update recipe.yaml

* update full mock_gripper edit_id example

* update full mock_gripper edit_id example

* get and update resource also in protocol_node

* mock resource update in AichemecoHiwo

* feat: add other jiageng PLC device code

* ilabos compile

* correct format

* correct recipe format

* correct setup.py format

* remove unnecessary files

* remove unnecessary files

* Create HT_hiwo.json

* add children in resources

* hplc support sample_id

* correct hplc sample_id

* correct hplc sample_id

* hplc upload

* fix type hint

* oss upload tested ver

* recipe yaml fix for linux

* update installation yaml

* refactor: moved all driver files according to its feat

* merge main to dev

* add HPLC registry and json

* 升级 ros2-distro-mutex 依赖版本至 0.6

* 修改 ros2-distro-mutex 依赖版本为通配符匹配

* 更新 ros-humble-ilabos-msgs 依赖为 robostack-humble 命名空间

* add resource type conversion to PLR

* add resource registry and test

* feat: 更新oss上传

* fix device id

* add docs

* fix registry

* add solenoid_valve_mock, its registry and test

* fix registry for directly using examples

* add EvacuateAndRefillProtocol and testcases

* allow function sequence call in ACTION

* add read & write & extra_info for hardware_interface

* Update device_node.py

* add solenoid valve

* add doc developer guide yaml

* use robostack-staging

* rclpy version test

* lower rclpy

* ensure 0.6* env

* fixes for starting IK station

* add graphml grouping parser

* fix graphml grouping parser

* add communication edge parser

* fix io solenoid valve

* Update .gitignore

* Update plates.yaml

* Feature/device node later init (#40)

* 修改config路径,方便后续打包
增加device_node打印

* 支持plr序列化/init创建

* 统一命名

* import mgr
logger optimize
banner print

* 日志OK

* fix unicorn frame

* banner print

* correct import format

* file path changes

* 取消后补全,在加载设备的时候直接替换

* converter update

* web page update

* 在线device更新,node继承替换

* 修复动作、状态的类型缺失 和 命令提示

* web功能实现结束

* host节点更改完成
新增status时间戳管理
新增每10s动态发现其他node

* ros2类型的节点也应该被包一次

* 修复类型提示

* websocket 动态显示状态

* add workflow & book theme for docs

* add workflow & book theme for docs

* fix workflow build

* fix workflow build

* 理清启动关系

* stm32 example

* mac . name

* device_instance device_cls

* 新增config添加方式
更新mqtt提示

* plr test

* 移动is_host_mode
新增slave_no_host

* 确保config优先修改生效

* fix graph io

* 支持带参数传入

* 支持物料解析

* 支持物料解析

* device为空的时候不进行绑定或初始化

* protocol node new

* protocol node runnable

* protocol node runnable

---------

Co-authored-by: 王俊杰 <2201110460@stu.pku.edu.cn>
Co-authored-by: Junhan Chang <changjh@pku.edu.cn>
Co-authored-by: jiawei <miaojiawei@dp.tech>
Co-authored-by: ColumbiaCC <2100011801@stu.pku.edu.cn>

* Dev (#45)

* Integration with pywinauto & recorder
Added execute run and initialize procdure

* 酶标仪状态检测、使用示例,整体流程待测试

* nivo ready version

* Add HPLC driver and example script

- Introduced HPLCDriver class for managing HPLC device status and operations.
- Implemented device status monitoring and command execution via ROS2 actions.
- Added example script (hplc.py) demonstrating how to run commands on the HPLC device.
- Created PlayerUtil and UniversalDriver classes for shared functionality across devices.
- Refactored NivoDriver to utilize the new UniversalDriver structure.
- Enhanced error handling and process management in the NivoDriver.

* 修复start的错误定位

* hplc tested ok

* relative path to build msgs

* template_driver & jiageng devices

* fetch correct status type and action type

* fix mtype fetch

* gpc bus integration

* ilab build

* remove chs

* recipe rename

* modbus update 1

* json available

* hplc & modbus rewrite

* Update AgilentHPLC.py

hplc datafile reader

* move ilabos/ros/rpc to ilabos/device_comms/rpc, and merge bioyond/aichemeco files under /devices

* modbus分设备

* gpc

* gpc 2

* fix address

* default register node

* fix MainScreenGPC

* add Resource srv and message_converter

* move graphio to ilabos/resources

* refactor resources type conversion

* add resource clients in device_node

* add mock resources service

* pass Gripper1 resource test

* update http resource services

* add AGV compile function

* add AGV transfer protocol

* update recipe.yaml

* update full mock_gripper edit_id example

* update full mock_gripper edit_id example

* get and update resource also in protocol_node

* mock resource update in AichemecoHiwo

* feat: add other jiageng PLC device code

* ilabos compile

* correct format

* correct recipe format

* correct setup.py format

* remove unnecessary files

* remove unnecessary files

* Create HT_hiwo.json

* add children in resources

* hplc support sample_id

* correct hplc sample_id

* correct hplc sample_id

* hplc upload

* fix type hint

* oss upload tested ver

* recipe yaml fix for linux

* update installation yaml

* refactor: moved all driver files according to its feat

* merge main to dev

* add HPLC registry and json

* 升级 ros2-distro-mutex 依赖版本至 0.6

* 修改 ros2-distro-mutex 依赖版本为通配符匹配

* 更新 ros-humble-ilabos-msgs 依赖为 robostack-humble 命名空间

* add resource type conversion to PLR

* add resource registry and test

* feat: 更新oss上传

* fix device id

* add docs

* fix registry

* add solenoid_valve_mock, its registry and test

* fix registry for directly using examples

* add EvacuateAndRefillProtocol and testcases

* allow function sequence call in ACTION

* add read & write & extra_info for hardware_interface

* Update device_node.py

* add solenoid valve

* add doc developer guide yaml

* use robostack-staging

* rclpy version test

* lower rclpy

* ensure 0.6* env

* fixes for starting IK station

* add graphml grouping parser

* fix graphml grouping parser

* add communication edge parser

* fix io solenoid valve

* Update .gitignore

* Update plates.yaml

* Feature/device node later init (#40)

* 修改config路径,方便后续打包
增加device_node打印

* 支持plr序列化/init创建

* 统一命名

* import mgr
logger optimize
banner print

* 日志OK

* fix unicorn frame

* banner print

* correct import format

* file path changes

* 取消后补全,在加载设备的时候直接替换

* converter update

* web page update

* 在线device更新,node继承替换

* 修复动作、状态的类型缺失 和 命令提示

* web功能实现结束

* host节点更改完成
新增status时间戳管理
新增每10s动态发现其他node

* ros2类型的节点也应该被包一次

* 修复类型提示

* websocket 动态显示状态

* add workflow & book theme for docs

* add workflow & book theme for docs

* fix workflow build

* fix workflow build

* 理清启动关系

* stm32 example

* mac . name

* device_instance device_cls

* 新增config添加方式
更新mqtt提示

* plr test

* 移动is_host_mode
新增slave_no_host

* 确保config优先修改生效

* fix graph io

* 支持带参数传入

* 支持物料解析

* 支持物料解析

* device为空的时候不进行绑定或初始化

* protocol node new

* protocol node runnable

* protocol node runnable

* Feature/device node later init (#42)

* 修改config路径,方便后续打包
增加device_node打印

* 支持plr序列化/init创建

* 统一命名

* import mgr
logger optimize
banner print

* 日志OK

* fix unicorn frame

* banner print

* correct import format

* file path changes

* 取消后补全,在加载设备的时候直接替换

* converter update

* web page update

* 在线device更新,node继承替换

* 修复动作、状态的类型缺失 和 命令提示

* web功能实现结束

* host节点更改完成
新增status时间戳管理
新增每10s动态发现其他node

* ros2类型的节点也应该被包一次

* 修复类型提示

* websocket 动态显示状态

* add workflow & book theme for docs

* add workflow & book theme for docs

* fix workflow build

* fix workflow build

* 理清启动关系

* stm32 example

* mac . name

* device_instance device_cls

* 新增config添加方式
更新mqtt提示

* plr test

* 移动is_host_mode
新增slave_no_host

* 确保config优先修改生效

* fix graph io

* 支持带参数传入

* 支持物料解析

* 支持物料解析

* device为空的时候不进行绑定或初始化

* protocol node new

* protocol node runnable

* protocol node runnable

* action

* plr suc

* plr suc!!

* plr suc!!

* plr suc!!

* plr msgs

* Feature/device node later init (#43)

* 修改config路径,方便后续打包
增加device_node打印

* 支持plr序列化/init创建

* 统一命名

* import mgr
logger optimize
banner print

* 日志OK

* fix unicorn frame

* banner print

* correct import format

* file path changes

* 取消后补全,在加载设备的时候直接替换

* converter update

* web page update

* 在线device更新,node继承替换

* 修复动作、状态的类型缺失 和 命令提示

* web功能实现结束

* host节点更改完成
新增status时间戳管理
新增每10s动态发现其他node

* ros2类型的节点也应该被包一次

* 修复类型提示

* websocket 动态显示状态

* add workflow & book theme for docs

* add workflow & book theme for docs

* fix workflow build

* fix workflow build

* 理清启动关系

* stm32 example

* mac . name

* device_instance device_cls

* 新增config添加方式
更新mqtt提示

* plr test

* 移动is_host_mode
新增slave_no_host

* 确保config优先修改生效

* fix graph io

* 支持带参数传入

* 支持物料解析

* 支持物料解析

* device为空的时候不进行绑定或初始化

* protocol node new

* protocol node runnable

* protocol node runnable

* action

* plr suc

* plr suc!!

* plr suc!!

* plr suc!!

* plr msgs

* plr

* action

* plr reg fix

* Feature/device node later init (#44)

* 修改config路径,方便后续打包
增加device_node打印

* 支持plr序列化/init创建

* 统一命名

* import mgr
logger optimize
banner print

* 日志OK

* fix unicorn frame

* banner print

* correct import format

* file path changes

* 取消后补全,在加载设备的时候直接替换

* converter update

* web page update

* 在线device更新,node继承替换

* 修复动作、状态的类型缺失 和 命令提示

* web功能实现结束

* host节点更改完成
新增status时间戳管理
新增每10s动态发现其他node

* ros2类型的节点也应该被包一次

* 修复类型提示

* websocket 动态显示状态

* add workflow & book theme for docs

* add workflow & book theme for docs

* fix workflow build

* fix workflow build

* 理清启动关系

* stm32 example

* mac . name

* device_instance device_cls

* 新增config添加方式
更新mqtt提示

* plr test

* 移动is_host_mode
新增slave_no_host

* 确保config优先修改生效

* fix graph io

* 支持带参数传入

* 支持物料解析

* 支持物料解析

* device为空的时候不进行绑定或初始化

* protocol node new

* protocol node runnable

* protocol node runnable

* action

* plr suc

* plr suc!!

* plr suc!!

* plr suc!!

* plr msgs

* plr

* fix convert error
fix async logic error
added async error print

* new device test

* test resource add

* test resource add

* test resource add

* test resource add

* local env setup

* node type fix
temp fix root_node error
fix convert res from type error

* resource tracker

* fix bug from qhh

* fix bug from qhh

* fix bug from qhh

* fix bug from qhh

* refactor MQTT client logging and connection handling; update group ID in config

* driver_params allow empty

* allow other init param

* fix driver param and enhance type hint

* refactor MQConfig to use double quotes for string literals

* fix wrong function calling

* fix wrong function calling

* fix log for mac

* fix networkx compatibility

* add mqtt loggers

* add action to jsonschema converter

* random client id

* type converter & registry

* correct conversion

* fix action publish only from discovered devices

* add "Bio" tag for action doc generation

* 改进module提示

* Fix doc

* mqtt不连接也可用
性价样例提示

* add docs

* 更新plr test案例

* Update intro.md

* 更新有机案例

* skip

---------

Co-authored-by: Harvey Que <Q-Query@outlook.com>
Co-authored-by: Junhan Chang <1700011741@pku.edu.cn>

---------

Co-authored-by: 王俊杰 <2201110460@stu.pku.edu.cn>
Co-authored-by: Junhan Chang <changjh@pku.edu.cn>
Co-authored-by: jiawei <miaojiawei@dp.tech>
Co-authored-by: ColumbiaCC <2100011801@stu.pku.edu.cn>
Co-authored-by: Harvey Que <Q-Query@outlook.com>
Co-authored-by: Junhan Chang <1700011741@pku.edu.cn>

* Canonicalize before Open Source (#46)

* big big refactor try01

* refactor 02

---------

Co-authored-by: ck <xiaoyeqiannian@163.com>
Co-authored-by: 王俊杰 <1800011822@pku.edu.cn>
Co-authored-by: q434343 <554662886@qq.com>
Co-authored-by: Xuwznln <xuwznln@gmail.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Co-authored-by: wjjxxx <43375851+wjjxxx@users.noreply.github.com>
Co-authored-by: 3218923350 <105201755+3218923350@users.noreply.github.com>
Co-authored-by: Xuwznln <1023025701@qq.com>
Co-authored-by: 王俊杰 <2201110460@stu.pku.edu.cn>
Co-authored-by: jiawei <miaojiawei@dp.tech>
Co-authored-by: Jiawei <91898272+jiawei723@users.noreply.github.com>
Co-authored-by: ColumbiaCC <2100011801@stu.pku.edu.cn>
Co-authored-by: Harvey Que <Q-Query@outlook.com>
This commit is contained in:
Junhan Chang
2025-04-17 15:09:58 +08:00
committed by GitHub
parent 7ccb425e39
commit a62a695812
266 changed files with 40772 additions and 2 deletions

35
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,35 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is. The bug may results in:
- abnormal interruption of the program,
- systematic or randomized numerical error, or
- relatively low efficiency.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

53
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: 构建文档
on:
push:
branches: [ main, dev, 'feature/device_node_later_init' ]
paths:
- 'docs/**'
- '.github/workflows/docs.yml'
pull_request:
branches: [ main, dev, 'feature/device_node_later_init' ]
paths:
- 'docs/**'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: 设置Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: 安装依赖
run: |
python -m pip install --upgrade pip
pip install sphinx sphinx-rtd-theme myst-parser sphinx-book-theme
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: 调试信息
run: |
echo "Python版本:"
python --version
echo "Sphinx版本:"
pip show sphinx
echo "查看docs目录结构:"
ls -la docs/
- name: 构建文档
run: |
cd docs
mkdir -p _static
make html
- name: 部署到GitHub Pages
if: github.event_name != 'pull_request'
uses: peaceiris/actions-gh-pages@v3
with:
deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }}
publish_dir: ./docs/_build/html
destination_dir: ${{ github.ref_name }}

183
.gitignore vendored
View File

@@ -1,8 +1,185 @@
## Python
# Byte-compiled / optimized / DLL files
__pycache__/
.idea
.vscode
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
## ROS
devel/
logs/
log/
build/
bin/
lib/
install/
*/devel/
*/logs/
*/log/
*/build/
*/bin/
*/lib/
*/install/
msg_gen/
srv_gen/
msg/*Action.msg
@@ -49,3 +226,9 @@ qtcreator-*
# Catkin custom files
CATKIN_IGNORE
.DS_Store
local_config.py
*.graphml

View File

@@ -1,2 +0,0 @@
# ILabOS
Laboratory Automation with AI and SImulation Environment

20
docs/Makefile Normal file
View File

@@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

View File

@@ -0,0 +1,10 @@
# 启动样例
本章节提供了几个典型的启动样例,帮助您快速了解和使用系统。每个样例都包含了详细的配置说明、文件解读以及操作步骤,便于您参考和实践。
```{toctree}
:maxdepth: 1
liquid_handler.md
organic_synthesis.md
```

View File

@@ -0,0 +1,100 @@
# 移液站样例
本样例介绍如何配置和启动移液站设备,并执行基本操作如插入枪头等。
## 准备工作
### 设备配置文件
移液站设备的完整配置可在 `test/experiments/plr_test.json` 文件中找到。该配置文件采用平展结构,通过 `type` 字段区分物料和设备,并通过 `parent``children` 字段实现层级关系。
配置文件示例片段:
```json
{
"nodes": [
{
"id": "PLR_STATION",
"name": "PLR_LH_TEST",
"parent": null,
"type": "device",
"class": "liquid_handler",
"config": {},
"data": {},
"children": [
"deck"
]
},
{
"id": "deck",
"name": "deck",
"type": "container",
"class": null,
"parent": "PLR_STATION",
"children": [
"trash",
"trash_core96",
"teaching_carrier",
"tip_rack",
"plate"
]
}
],
"links": []
}
```
配置文件定义了移液站的组成部分,主要包括:
- 移液站本体LiquidHandler- 设备类型
- 移液站携带物料实例deck- 物料类型
## 启动方法
### 1. 启动移液站节点
使用以下命令启动移液站设备:
```bash
unilab -g test/experiments/plr_test.json --app_bridges ""
```
### 2. 执行枪头插入操作
启动后,您可以使用以下命令执行插入枪头操作:
```bash
ros2 action send_goal /devices/PLR_STATION/pick_up_tips unilabos_msgs/action/_liquid_handler_pick_up_tips/LiquidHandlerPickUpTips "{ tip_spots: [ { id: 'tip_rack_tipspot_0_0', name: 'tip_rack_tipspot_0_0', sample_id: null, children: [], parent: 'tip_rack', type: 'device', config: { position: { x: 7.2, y: 68.3, z: -83.5 }, size_x: 9.0, size_y: 9.0, size_z: 0, rotation: { x: 0, y: 0, z: 0, type: 'Rotation' }, category: 'tip_spot', model: null, type: 'TipSpot', prototype_tip: { type: 'HamiltonTip', total_tip_length: 95.1, has_filter: true, maximal_volume: 1065, pickup_method: 'OUT_OF_RACK', tip_size: 'HIGH_VOLUME' } }, data: { tip: { type: 'HamiltonTip', total_tip_length: 95.1, has_filter: true, maximal_volume: 1065, pickup_method: 'OUT_OF_RACK', tip_size: 'HIGH_VOLUME' }, tip_state: { liquids: [], pending_liquids: [], liquid_history: [] }, pending_tip: { type: 'HamiltonTip', total_tip_length: 95.1, has_filter: true, maximal_volume: 1065, pickup_method: 'OUT_OF_RACK', tip_size: 'HIGH_VOLUME' } } } ], use_channels: [ 0 ], offsets: [ { x: 0.0, y: 0.0, z: 0.0 } ] }"
```
此命令会通过ros通信触发移液站执行枪头插入操作得到如下的PyLabRobot的输出日志。
```log
Picking up tips:
pip# resource offset tip type max volume (µL) fitting depth (mm) tip length (mm) filter
p0: tip_rack_tipspot_0_0 0.0,0.0,0.0 HamiltonTip 1065 8 95.1 Yes
```
## 常见问题
1. **重复插入枪头不成功**操作编排应该符合实际操作顺序可自行通过PyLabRobot进行测试
## 移液站支持的操作
移液站支持多种操作,以下是当前系统支持的操作列表:
1. **LiquidHandlerAspirate** - 吸液操作
2. **LiquidHandlerDispense** - 排液操作
3. **LiquidHandlerDiscardTips** - 丢弃枪头
4. **LiquidHandlerDropTips** - 卸下枪头
5. **LiquidHandlerDropTips96** - 卸下96通道枪头
6. **LiquidHandlerMoveLid** - 移动盖子
7. **LiquidHandlerMovePlate** - 移动板子
8. **LiquidHandlerMoveResource** - 移动资源
9. **LiquidHandlerPickUpTips** - 插入枪头
10. **LiquidHandlerPickUpTips96** - 插入96通道枪头
11. **LiquidHandlerReturnTips** - 归还枪头
12. **LiquidHandlerReturnTips96** - 归还96通道枪头
13. **LiquidHandlerStamp** - 打印标记
14. **LiquidHandlerTransfer** - 液体转移
这些操作可通过ROS2 Action接口进行调用以实现复杂的移液流程。

View File

@@ -0,0 +1,105 @@
# 有机常量合成样例
本样例演示如何配置和操作有机常量合成工作站,实现抽真空和充气等基本操作。
## 准备工作
### 设备配置文件
有机常量合成工作站的完整配置可在 `test/experiments/mock_reactor.json` 文件中找到。该配置文件采用平展结构,通过 `type` 字段区分物料和设备,并通过 `parent``children` 字段实现层级关系。
配置文件示例片段:
```json
{
"nodes": [
{
"id": "ReactorX",
"children": [
"reactor",
"vacuum_valve",
"gas_valve",
"vacuum_pump",
"gas_source"
],
"parent": null,
"type": "device",
"class": "workstation"
},
{
"id": "reactor",
"parent": "ReactorX",
"type": "container"
},
{
"id": "vacuum_valve",
"parent": "ReactorX",
"type": "device"
},
{
"id": "gas_valve",
"parent": "ReactorX",
"type": "device"
},
{
"id": "vacuum_pump",
"parent": "ReactorX",
"type": "device"
},
{
"id": "gas_source",
"parent": "ReactorX",
"type": "device"
}
],
"links": [
{
"source": "reactor",
"target": "vacuum_valve"
},
{
"source": "reactor",
"target": "gas_valve"
},
{
"source": "vacuum_pump",
"target": "vacuum_valve"
},
{
"source": "gas_source",
"target": "gas_valve"
}
]
}
```
配置文件定义了反应系统的组成部分,主要包括:
1. **反应工作站 (ReactorX)** - 整个系统的父节点,包含所有子组件
2. **反应器 (reactor)** - 实际进行反应的容器
3. **真空阀 (vacuum_valve)** - 连接反应器和真空泵
4. **气体阀 (gas_valve)** - 连接反应器和气源
5. **真空泵 (vacuum_pump)** - 用于抽真空
6. **气源 (gas_source)** - 提供充气
这些组件通过链接关系形成一个完整的气路系统,可以实现抽真空和充气的功能。
## 启动方法
### 1. 启动反应器节点
使用以下命令启动模拟反应器:
```bash
unilab -g test/experiments/mock_reactor.json --app_bridges ""
```
### 2. 执行抽真空和充气操作
启动后,您可以使用以下命令执行抽真空操作:
```bash
ros2 action send_goal /devices/ReactorX/EvacuateAndRefillProtocol unilabos_msgs/action/EvacuateAndRefill "{vessel: reactor, gas: N2, repeats: 2}"
```
此命令会通过ros通信触发工作站执行抽真空和充气的协议操作与此同时您可以通过 http://localhost:8002/status 在`主机节点信息`-`设备状态`查看该操作对设备开关的实时效果。

View File

@@ -0,0 +1,45 @@
(instructions)=
# 设备抽象、指令集与通信中间件
Uni-Lab 操作系统的目的是将不同类型和厂家的实验仪器进行抽象统一,对应用层提供服务。因此,理清实验室设备之间的业务逻辑至关重要。
## 设备间通信模式
### **[话题(一对多发送,一对多订阅)](https://book.guyuehome.com/ROS2/2.%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5/2.4_%E8%AF%9D%E9%A2%98/)**
典型的话题通信有:
* 传感器连续发送设备状态和数据;
* 连续时间控制器发送控制指令,如控温、连续称量、机械臂轨迹跟随、视觉识别操作等
![](image/01-communication-instruction/topic.gif)
### **[服务(短时请求与响应)](https://book.guyuehome.com/ROS2/2.%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5/2.5_%E6%9C%8D%E5%8A%A1/)**
典型的服务通信有:
* 查/改全局参数如物料、设备
* 使用其他通信接口发送/接收数据
![](image/01-communication-instruction/service.gif)
### **[动作(长时任务启动,随后连续收到反馈值,直到达到目标)](https://book.guyuehome.com/ROS2/2.%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5/2.7_%E5%8A%A8%E4%BD%9C/)**
动作机制主要用于处理运行时长较长的单点任务或任务组合,如:
* 执行工作流
* 执行工作流的子动作
![](image/01-communication-instruction/action.gif)
## 通信指令集
Uni-Lab 目前使用 ROS2 作为通信中间件,因此大量使用其标准消息作为话题、服务、动作。新增指令位于仓库中的 `unilabos_msgs` ,各类实验动作指令集分类整理于 {ref}`actions`
## 通信中间件层
通信中间件层的一个重要设计思想是:将业务逻辑开发,与实际部署中的通信和运行解耦。开发者在实现具体业务逻辑时,可以不用关心最终运行时的 **部署方式****通信方式** 。当用户开发完成后,再根据实际情况决定部署、通信方案。
* 对于 **“流动化学实验室”和“桌面机器人”** 来说,一台电脑通过串口控制所有设备足够。**此时在这台电脑启动 Uni-Lab 作为 Server 即可。**
* 对于 **“移动机器人”大型实验室** ,典型场景是,一个实验室由多台不同位置的工作站组成,每台大型设备有一台工控机,通过串口再控制子设备。同时有 AGV/机械臂 负责转运。**此时,在每台工控机启动 Uni-Lab完成通信中间件层的包装之后只要处于同一局域网下他们将能自动互相发现并组成分布式的“Uni-Lab-Edge Server”。**
* 通信中间件层的分布式机制,使得 Node 之间做好了隔离,一台设备故障时只需重启单个 Node。很像微服务、微内核的设计理念。

View File

@@ -0,0 +1,79 @@
(graph)=
# 实验室组态图
组态(configuration)图是指在自动化领域中,用来描述和展示控制系统中各个组件之间关系的图形化表示方法。
它是一个系统的框架图,通过图形符号和连接线,将各个组件(如传感器、执行器、控制器等)以及它们之间的关系进行可视化展示。
Uni-Lab 的组态图当前支持 node-link json 和 graphml 格式其中包含4类重要信息
* 单个设备/物料配置,即图中节点的参数;
* 父子关系,如一台工作站包含它的多个子设备、放置着多个物料耗材;
* 物理连接关系如流体管路连接、AGV/机械臂/直线模组转运连接。
* 通信转接关系,如多个 IO 设备通过 IO 板卡或 PLC 转为 Modbus串口转网口等
* 控制逻辑关系,如某个输出量被某个输入量 PID 控制
## 父子关系、物质流与"编译"操作
在计算机操作系统下,软件操作数据和文件。在实验操作系统下,实验“软件”利用仪器“硬件”操作物质。实验人员能理解的操作,最终都是对物质的处理。将实验步骤,转化为硬件指令,这个操作我们可以类比为“编译”。
对用户来说,“直接操作设备执行单个指令”不是个真实需求,真正的需求是**“执行对实验有意义的单个完整动作”——加入某种液体多少量;萃取分液;洗涤仪器等等。就像实验步骤文字书写的那样。**
而这些对实验有意义的单个完整动作,**一般需要多个设备的协同**,还依赖于他们的**物理连接关系(管道相连;机械臂可转运)**。
于是 Uni-Lab 实现了抽象的“工作站”,即注册表中的 `workstation` 设备(`ProtocolNode`类)来处理编译、规划操作。以泵骨架组成的自动有机实验室为例,设备管道连接关系如下:
![topology](image/02-topology-and-chemputer-compile/topology.png)
接收“移液”动作,编译为一系列泵指令和阀指令
```text
Goal received: {
'from_vessel': 'flask_acetone',
'to_vessel': 'reactor',
'volume': 2000.0,
'flowrate': 100.0
}, running steps:
```
```JSON
[
{
"device_id": "pump_reagents",
"action_name": "set_valve_position",
"action_kwargs": {"command": "3"}
},
{
"device_id": "pump_reagents",
"action_name": "set_position",
"action_kwargs": {
"position": 2000.0,
"max_velocity": 100.0
}
},
{
"device_id": "pump_reagents",
"action_name": "set_valve_position",
"action_kwargs": {"command": '5'}
},
{
"device_id": "pump_reagents",
"action_name": "set_position",
"action_kwargs": {
"position": 0.0,
"max_velocity": 100.0
}
}
]
```
若想开发新的“编译”/“规划”功能,在 `unilabos/compilers` 实现一个新函数即可。详情请见 [添加新实验操作Protocol](../developer_guide/add_protocol.md)
## 通信转接关系
Uni-Lab 秉持着**通信逻辑**与**业务逻辑**分离的设计理念,以追求实验设备、通信设备最大的代码复用性。
如对 IO 板卡8路 IO 控制的8个电磁阀经过设备抽象层后仅有逻辑意义上分立的8个电磁阀。在组态图中他们通过代表通信关系的边edge与 IO 板卡设备相连,边的属性包含 IO 地址。
在实际使用中,对抽象的电磁阀发送开关动作请求,将通过 Uni-Lab 设备抽象&通信中间件转化为对 IO 板卡发送 IO 读写指令。因此一定意义上,组态图的通信转接部分代表了简化的电气接线图。
代码架构上,实验设备位于 Uni-Lab 仓库/注册表的 `devices` 目录,通信设备位于 Uni-Lab 仓库/注册表的 `device_comms` 目录。
![configuration](image/01-communication-instruction/configuration.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

View File

@@ -0,0 +1,3 @@
# Uni-Lab 操作系统总览
![Layers](image/overview/Uni-Lab-layers.png)![Layers](image/overview/Uni-Lab-whiteboard.png)

205
docs/conf.py Normal file
View File

@@ -0,0 +1,205 @@
# Configuration file for the Sphinx documentation builder.
# Sphinx 文档生成器的配置文件
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import os
import sys
# 将项目的根目录添加到 sys.path 中,以便 Sphinx 能够找到 unilabos 包
sys.path.insert(0, os.path.abspath(".."))
project = "Uni-Lab"
copyright = "2025, Uni-Lab Community, DP Technology & Peking University"
author = "Uni-Lab Community, DP Technology & Peking University"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
"myst_parser",
"sphinx.ext.autodoc",
"sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings
"sphinx_rtd_theme"
]
source_suffix = {
".rst": "restructuredtext",
".txt": "markdown",
".md": "markdown",
}
myst_enable_extensions = [
"colon_fence",
"deflist",
"dollarmath",
"html_image",
"replacements",
"smartquotes",
"substitution",
]
templates_path = ["_templates"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
language = "zh"
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
# 设置 HTML 主题为 sphinx-book-theme
html_theme = "sphinx_rtd_theme"
# sphinx-book-theme 主题选项
html_theme_options = {
"repository_url": "https://github.com/用户名/Uni-Lab",
"use_repository_button": True,
"use_issues_button": True,
"use_edit_page_button": True,
"use_download_button": True,
"path_to_docs": "docs",
"show_navbar_depth": 2,
"show_toc_level": 2,
"home_page_in_toc": True,
"logo_only": False,
}
# 设置 HTML 文档的静态文件路径
html_static_path = ["_static"] # 如果有自定义 CSS可以放在 _static 目录中
section_titles = {
"Simple": "## 简单单变量动作函数",
"Organic": """## 常量有机化学操作
Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab.io/chemputer/xdl/standard/full_steps_specification.html#),包含有机合成实验中常见的操作,如加热、搅拌、冷却等。
""",
"Bio": """## 移液工作站及相关生物自动化设备操作
Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.org/user_guide/index.html),包含生物实验中常见的操作,如移液、混匀、离心等。
""",
"MobileRobot": "## 多工作站及小车运行、物料转移",
"Robot": """## 机械臂、夹爪等机器人设备
Uni-Lab 机械臂、机器人、夹爪和导航指令集沿用 ROS2 的 `control_msgs` 和 `nav2_msgs`
""",
}
import os
from pathlib import Path
def get_conda_share_dir(package_name=None):
"""获取 Conda 环境的 share 目录路径
:param package_name: 可选参数,指定具体包的 share 子目录
:return: Path 对象或 None
"""
# 获取当前 Conda 环境根目录
conda_prefix = os.getenv("CONDA_PREFIX")
if not conda_prefix:
raise EnvironmentError("未检测到激活的 Conda 环境")
# 构建基础 share 目录路径
share_dir = Path(conda_prefix) / "share"
# 如果指定了包名,追加包子目录
if package_name:
share_dir = share_dir / package_name
# 验证路径是否存在
if not share_dir.exists():
print(f"警告: 路径 {share_dir} 不存在")
return None
return share_dir
def generate_action_includes(app):
src_dir = Path(app.srcdir)
print(f"Generating action includes for {src_dir}")
action_dir = src_dir.parent / "unilabos_msgs" / "action" # 修改为你的实际路径
output_file = src_dir / "developer_guide" / "action_includes.md"
# 确保输出目录存在
output_file.parent.mkdir(exist_ok=True)
# 初始化各部分内容
sections = {}
# 仅处理本地消息文件
if action_dir.exists():
for action_file in sorted(action_dir.glob("*.action")):
# 获取相对路径
rel_path = f"../../unilabos_msgs/action/{action_file.name}"
# 读取首行注释
try:
with open(action_file, "r", encoding="utf-8") as af:
first_line = af.readline().strip()
# 提取注释内容(去除#和空格)
section = first_line.lstrip("#").strip()
text = f"""
### `{action_file.stem}`
```{{literalinclude}} {rel_path}
:language: yaml
```
----
"""
if sections.get(section) is None:
sections[section] = text
else:
sections[section] += text
except Exception as e:
print(f"处理文件 {action_file} 时出错: {e}")
else:
print(f"警告: 动作消息目录 {action_dir} 不存在")
ros_action_dirs = []
control_msgs_dir = get_conda_share_dir("control_msgs")
nav2_msgs_dir = get_conda_share_dir("nav2_msgs")
if control_msgs_dir is not None:
ros_action_dirs.append(control_msgs_dir / "action")
if nav2_msgs_dir is not None:
ros_action_dirs.append(nav2_msgs_dir / "action")
for action_dir in ros_action_dirs:
for action_file in sorted(action_dir.glob("*.action")):
# 获取相对路径
rel_path = f"{action_file.absolute()}"
# 读取首行注释
with open(action_file, "r", encoding="utf-8") as af:
# 提取注释内容(去除#和空格)
section = "Robot"
text = f"""### `{action_file.stem}`
```yaml
{open(rel_path, 'r').read()}
```
----
"""
if sections.get(section) is None:
sections[section] = text
else:
sections[section] += text
# 写入内容到输出文件
with open(output_file, "w", encoding="utf-8") as f:
# 按 Section 生成总文档
for section, title in section_titles.items():
content = sections.get(section, "")
if content: # 只有有内容时才写入标题和内容
f.write(f"{title}\n\n")
f.write(content)
def setup(app):
app.connect("builder-inited", generate_action_includes)

View File

@@ -0,0 +1,577 @@
## 简单单变量动作函数
### `SendCmd`
```{literalinclude} ../../unilabos_msgs/action/SendCmd.action
:language: yaml
```
----
## 常量有机化学操作
Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab.io/chemputer/xdl/standard/full_steps_specification.html#),包含有机合成实验中常见的操作,如加热、搅拌、冷却等。
### `Clean`
```{literalinclude} ../../unilabos_msgs/action/Clean.action
:language: yaml
```
----
### `EvacuateAndRefill`
```{literalinclude} ../../unilabos_msgs/action/EvacuateAndRefill.action
:language: yaml
```
----
### `Evaporate`
```{literalinclude} ../../unilabos_msgs/action/Evaporate.action
:language: yaml
```
----
### `HeatChill`
```{literalinclude} ../../unilabos_msgs/action/HeatChill.action
:language: yaml
```
----
### `HeatChillStart`
```{literalinclude} ../../unilabos_msgs/action/HeatChillStart.action
:language: yaml
```
----
### `HeatChillStop`
```{literalinclude} ../../unilabos_msgs/action/HeatChillStop.action
:language: yaml
```
----
### `PumpTransfer`
```{literalinclude} ../../unilabos_msgs/action/PumpTransfer.action
:language: yaml
```
----
### `Separate`
```{literalinclude} ../../unilabos_msgs/action/Separate.action
:language: yaml
```
----
### `Stir`
```{literalinclude} ../../unilabos_msgs/action/Stir.action
:language: yaml
```
----
## 移液工作站及相关生物自动化设备操作
Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.org/user_guide/index.html),包含生物实验中常见的操作,如移液、混匀、离心等。
### `LiquidHandlerAspirate`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerAspirate.action
:language: yaml
```
----
### `LiquidHandlerDiscardTips`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerDiscardTips.action
:language: yaml
```
----
### `LiquidHandlerDispense`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerDispense.action
:language: yaml
```
----
### `LiquidHandlerDropTips`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerDropTips.action
:language: yaml
```
----
### `LiquidHandlerDropTips96`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerDropTips96.action
:language: yaml
```
----
### `LiquidHandlerMoveLid`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveLid.action
:language: yaml
```
----
### `LiquidHandlerMovePlate`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMovePlate.action
:language: yaml
```
----
### `LiquidHandlerMoveResource`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveResource.action
:language: yaml
```
----
### `LiquidHandlerPickUpTips`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerPickUpTips.action
:language: yaml
```
----
### `LiquidHandlerPickUpTips96`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerPickUpTips96.action
:language: yaml
```
----
### `LiquidHandlerReturnTips`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerReturnTips.action
:language: yaml
```
----
### `LiquidHandlerReturnTips96`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerReturnTips96.action
:language: yaml
```
----
### `LiquidHandlerStamp`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerStamp.action
:language: yaml
```
----
### `LiquidHandlerTransfer`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransfer.action
:language: yaml
```
----
## 多工作站及小车运行、物料转移
### `AGVTransfer`
```{literalinclude} ../../unilabos_msgs/action/AGVTransfer.action
:language: yaml
```
----
### `WorkStationRun`
```{literalinclude} ../../unilabos_msgs/action/WorkStationRun.action
:language: yaml
```
----
## 机械臂、夹爪等机器人设备
Uni-Lab 机械臂、机器人、夹爪和导航指令集沿用 ROS2 的 `control_msgs` 和 `nav2_msgs`
### `FollowJointTrajectory`
```yaml
# The trajectory for all revolute, continuous or prismatic joints
trajectory_msgs/JointTrajectory trajectory
# The trajectory for all planar or floating joints (i.e. individual joints with more than one DOF)
trajectory_msgs/MultiDOFJointTrajectory multi_dof_trajectory
# Tolerances for the trajectory. If the measured joint values fall
# outside the tolerances the trajectory goal is aborted. Any
# tolerances that are not specified (by being omitted or set to 0) are
# set to the defaults for the action server (often taken from the
# parameter server).
# Tolerances applied to the joints as the trajectory is executed. If
# violated, the goal aborts with error_code set to
# PATH_TOLERANCE_VIOLATED.
JointTolerance[] path_tolerance
JointComponentTolerance[] component_path_tolerance
# To report success, the joints must be within goal_tolerance of the
# final trajectory value. The goal must be achieved by time the
# trajectory ends plus goal_time_tolerance. (goal_time_tolerance
# allows some leeway in time, so that the trajectory goal can still
# succeed even if the joints reach the goal some time after the
# precise end time of the trajectory).
#
# If the joints are not within goal_tolerance after "trajectory finish
# time" + goal_time_tolerance, the goal aborts with error_code set to
# GOAL_TOLERANCE_VIOLATED
JointTolerance[] goal_tolerance
JointComponentTolerance[] component_goal_tolerance
builtin_interfaces/Duration goal_time_tolerance
---
int32 error_code
int32 SUCCESSFUL = 0
int32 INVALID_GOAL = -1
int32 INVALID_JOINTS = -2
int32 OLD_HEADER_TIMESTAMP = -3
int32 PATH_TOLERANCE_VIOLATED = -4
int32 GOAL_TOLERANCE_VIOLATED = -5
# Human readable description of the error code. Contains complementary
# information that is especially useful when execution fails, for instance:
# - INVALID_GOAL: The reason for the invalid goal (e.g., the requested
# trajectory is in the past).
# - INVALID_JOINTS: The mismatch between the expected controller joints
# and those provided in the goal.
# - PATH_TOLERANCE_VIOLATED and GOAL_TOLERANCE_VIOLATED: Which joint
# violated which tolerance, and by how much.
string error_string
---
std_msgs/Header header
string[] joint_names
trajectory_msgs/JointTrajectoryPoint desired
trajectory_msgs/JointTrajectoryPoint actual
trajectory_msgs/JointTrajectoryPoint error
string[] multi_dof_joint_names
trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_desired
trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_actual
trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_error
```
----
### `GripperCommand`
```yaml
GripperCommand command
---
float64 position # The current gripper gap size (in meters)
float64 effort # The current effort exerted (in Newtons)
bool stalled # True iff the gripper is exerting max effort and not moving
bool reached_goal # True iff the gripper position has reached the commanded setpoint
---
float64 position # The current gripper gap size (in meters)
float64 effort # The current effort exerted (in Newtons)
bool stalled # True iff the gripper is exerting max effort and not moving
bool reached_goal # True iff the gripper position has reached the commanded setpoint
```
----
### `JointTrajectory`
```yaml
trajectory_msgs/JointTrajectory trajectory
---
---
```
----
### `PointHead`
```yaml
geometry_msgs/PointStamped target
geometry_msgs/Vector3 pointing_axis
string pointing_frame
builtin_interfaces/Duration min_duration
float64 max_velocity
---
---
float64 pointing_angle_error
```
----
### `SingleJointPosition`
```yaml
float64 position
builtin_interfaces/Duration min_duration
float64 max_velocity
---
---
std_msgs/Header header
float64 position
float64 velocity
float64 error
```
----
### `AssistedTeleop`
```yaml
#goal definition
builtin_interfaces/Duration time_allowance
---
#result definition
builtin_interfaces/Duration total_elapsed_time
---
#feedback
builtin_interfaces/Duration current_teleop_duration
```
----
### `BackUp`
```yaml
#goal definition
geometry_msgs/Point target
float32 speed
builtin_interfaces/Duration time_allowance
---
#result definition
builtin_interfaces/Duration total_elapsed_time
---
#feedback definition
float32 distance_traveled
```
----
### `ComputePathThroughPoses`
```yaml
#goal definition
geometry_msgs/PoseStamped[] goals
geometry_msgs/PoseStamped start
string planner_id
bool use_start # If false, use current robot pose as path start, if true, use start above instead
---
#result definition
nav_msgs/Path path
builtin_interfaces/Duration planning_time
---
#feedback definition
```
----
### `ComputePathToPose`
```yaml
#goal definition
geometry_msgs/PoseStamped goal
geometry_msgs/PoseStamped start
string planner_id
bool use_start # If false, use current robot pose as path start, if true, use start above instead
---
#result definition
nav_msgs/Path path
builtin_interfaces/Duration planning_time
---
#feedback definition
```
----
### `DriveOnHeading`
```yaml
#goal definition
geometry_msgs/Point target
float32 speed
builtin_interfaces/Duration time_allowance
---
#result definition
builtin_interfaces/Duration total_elapsed_time
---
#feedback definition
float32 distance_traveled
```
----
### `DummyBehavior`
```yaml
#goal definition
std_msgs/String command
---
#result definition
builtin_interfaces/Duration total_elapsed_time
---
#feedback definition
```
----
### `FollowPath`
```yaml
#goal definition
nav_msgs/Path path
string controller_id
string goal_checker_id
---
#result definition
std_msgs/Empty result
---
#feedback definition
float32 distance_to_goal
float32 speed
```
----
### `FollowWaypoints`
```yaml
#goal definition
geometry_msgs/PoseStamped[] poses
---
#result definition
int32[] missed_waypoints
---
#feedback definition
uint32 current_waypoint
```
----
### `NavigateThroughPoses`
```yaml
#goal definition
geometry_msgs/PoseStamped[] poses
string behavior_tree
---
#result definition
std_msgs/Empty result
---
#feedback definition
geometry_msgs/PoseStamped current_pose
builtin_interfaces/Duration navigation_time
builtin_interfaces/Duration estimated_time_remaining
int16 number_of_recoveries
float32 distance_remaining
int16 number_of_poses_remaining
```
----
### `NavigateToPose`
```yaml
#goal definition
geometry_msgs/PoseStamped pose
string behavior_tree
---
#result definition
std_msgs/Empty result
---
#feedback definition
geometry_msgs/PoseStamped current_pose
builtin_interfaces/Duration navigation_time
builtin_interfaces/Duration estimated_time_remaining
int16 number_of_recoveries
float32 distance_remaining
```
----
### `SmoothPath`
```yaml
#goal definition
nav_msgs/Path path
string smoother_id
builtin_interfaces/Duration max_smoothing_duration
bool check_for_collisions
---
#result definition
nav_msgs/Path path
builtin_interfaces/Duration smoothing_duration
bool was_completed
---
#feedback definition
```
----
### `Spin`
```yaml
#goal definition
float32 target_yaw
builtin_interfaces/Duration time_allowance
---
#result definition
builtin_interfaces/Duration total_elapsed_time
---
#feedback definition
float32 angular_distance_traveled
```
----
### `Wait`
```yaml
#goal definition
builtin_interfaces/Duration time
---
#result definition
builtin_interfaces/Duration total_elapsed_time
---
#feedback definition
builtin_interfaces/Duration time_left
```
----

View File

@@ -0,0 +1,7 @@
(actions)=
# Uni-Lab 动作指令集
Uni-Lab 当前动作指令集设计时,主要考虑兼容领域历史开源工具。目前包括以下场景:
```{include} action_includes.md
```

View File

@@ -0,0 +1,37 @@
# 添加新动作指令Action
1.`unilabos_msgs/action` 中新建实验操作名和参数列表,如 `MyDeviceCmd.action`。一个 Action 定义由三个部分组成分别是目标Goal、结果Result和反馈Feedback之间使用 `---` 分隔:
```action
# 目标Goal
string command
---
# 结果Result
bool success
---
# 反馈Feedback
```
2.`unilabos_msgs/CMakeLists.txt` 中添加新定义的 action
```cmake
add_action_files(
FILES
MyDeviceCmd.action
)
```
3. 因为在指令集中新建了指令,因此调试时需要编译,并在终端环境中加载临时路径:
```bash
cd unilabos_msgs
colcon build
source ./install/local_setup.sh
cd ..
```
调试成功后,发起 pull requestUni-Lab 的 CI/CD 系统会自动将新的指令集编译打包mamba执行升级即可永久生效
```bash
mamba update ros-humble-unilabos-msgs -c http://quetz.dp.tech:8088/get/unilab -c robostack-humble -c robostack-staging
```

View File

@@ -0,0 +1,200 @@
# 添加新设备
在 Uni-Lab 中设备Device是实验操作的基础单元。Uni-Lab 使用**注册表机制**来兼容管理种类繁多的设备驱动程序。回顾 {ref}`instructions` 中的概念,抽象的设备对外拥有【话题】【服务】【动作】三种通信机制,因此将设备添加进 Uni-Lab实际上是将设备驱动中的三种机制映射到 Uni-Lab 标准指令集上。
能被 Uni-Lab 添加的驱动程序类型有以下种类:
1. Python Class
```python
class MockGripper:
def __init__(self):
self._position: float = 0.0
self._velocity: float = 2.0
self._torque: float = 0.0
self._status = "Idle"
@property
def position(self) -> float:
return self._position
@property
def velocity(self) -> float:
return self._velocity
@property
def torque(self) -> float:
return self._torque
# 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播
@property
def status(self) -> str:
return self._status
# 会被自动识别的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
@status.setter
def status(self, target):
self._status = target
# 需要在注册表添加的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
def push_to(self, position: float, torque: float, velocity: float = 0.0):
self._status = "Running"
current_pos = self.position
if velocity == 0.0:
velocity = self.velocity
move_time = abs(position - current_pos) / velocity
for i in range(20):
self._position = current_pos + (position - current_pos) / 20 * (i+1)
self._torque = torque / (20 - i)
self._velocity = velocity
time.sleep(move_time / 20)
self._torque = torque
self._status = "Idle"
```
Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 使用。
2. C# Class
```csharp
using System;
using System.Threading.Tasks;
public class MockGripper
{
// 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播
public double position { get; private set; } = 0.0;
public double velocity { get; private set; } = 2.0;
public double torque { get; private set; } = 0.0;
public string status { get; private set; } = "Idle";
// 需要在注册表添加的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令
public async Task PushToAsync(double Position, double Torque, double Velocity = 0.0)
{
status = "Running";
double currentPos = Position;
if (Velocity == 0.0)
{
velocity = Velocity;
}
double moveTime = Math.Abs(Position - currentPos) / velocity;
for (int i = 0; i < 20; i++)
{
position = currentPos + (Position - currentPos) / 20 * (i + 1);
torque = Torque / (20 - i);
velocity = Velocity;
await Task.Delay((int)(moveTime * 1000 / 20)); // Convert seconds to milliseconds
}
torque = Torque;
status = "Idle";
}
}
```
C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能使用,但只需一次。
## 注册表文件位置
Uni-Lab 启动时会自动读取默认注册表路径 `unilabos/registry/devices` 下的所有注册设备。您也可以任意维护自己的注册表路径,只需要在 Uni-Lab 启动时使用 `--registry` 参数将路径添加即可。
`<path-to-registry>/devices` 中新建一个 yaml 文件,即可开始撰写。您可以将多个设备写到同一个 yaml 文件中。
## 注册表的结构
1. 顶层名称:每个设备的注册表以设备名称开头,例如 `new_device`, `gripper.mock`
1. `class` 字段:定义设备的模块路径和驱动程序语言。
1. `status_types` 字段:定义设备定时对 Uni-Lab 实验室内发送的属性名及其类型。
1. `action_value_mappings` 字段:定义设备支持的动作及其目标、反馈和结果。
1. `schema` 字段:定义设备定时对 Uni-Lab 云端监控发送的属性名及其类型、描述(非必须)
## 创建新的注册表教程
1. 创建文件
在 devices 文件夹中创建一个新的 YAML 文件,例如 `new_device.yaml`
2. 定义设备名称
在文件中定义设备的顶层名称,例如:`new_device``gripper.mock`
3. 定义设备的类信息
添加设备的模块路径和类型:
```yaml
gripper.mock:
class: # 定义设备的类信息
module: unilabos.devices.gripper.mock:MockGripper
type: python # 指定驱动语言为 Python
status_types:
position: Float64
torque: Float64
status: String
```
4. 定义设备的定时发布属性。注意,对于 Python Class 来说PROP 是 class 的 `property`,或满足能被 `getattr(cls, PROP)``cls.get_PROP` 读取到的属性值的对象。
```yaml
status_types:
PROP: TYPE
```
5. 定义设备支持的动作
添加设备支持的动作及其目标、反馈和结果:
```yaml
action_value_mappings:
set_speed:
type: SendCmd
goal:
command: speed
feedback: {}
result:
success: success
```
在 devices 文件夹中的 YAML 文件中action_value_mappings 是用来将驱动内的动作函数,映射到 Uni-Lab 标准动作actions及其目标参数值goal、反馈值feedback和结果值result的映射规则。若在 Uni-Lab 指令集内找不到符合心意的,请【创建新指令】。
```yaml
action_value_mappings:
<action_name>: # <action_name>:动作的名称
# start启动设备或某个功能。
# stop停止设备或某个功能。
# set_speed设置设备的速度。
# set_temperature设置设备的温度。
# move_to_position移动设备到指定位置。
# stir执行搅拌操作。
# heatchill执行加热或冷却操作。
# send_nav_task发送导航任务例如机器人导航
# set_timer设置设备的计时器。
# valve_open_cmd打开阀门。
# valve_close_cmd关闭阀门。
# execute_command_from_outer执行外部命令。
# push_to控制设备推送到某个位置例如机械爪
# move_through_points导航设备通过多个点。
type: <ActionType> # 动作的类型,表示动作的功能
# 根据动作的功能选择合适的类型,请查阅 Uni-Lab 已支持的指令集。
goal: # 定义动作的目标值映射,表示需要传递给设备的参数。
<goal_key>: <mapped_value> #确定设备需要的输入参数,并将其映射到设备的字段。
feedback: # 定义动作的反馈值映射,表示设备执行动作时返回的实时状态。
<feedback_key>: <mapped_value>
result: # 定义动作的结果值映射,表示动作完成后返回的最终结果。
<result_key>: <mapped_value>
```
6. 定义设备的网页展示属性类型,这部分会被用于在 Uni-Lab 网页端渲染成状态监控
添加设备的属性模式,包括属性类型和描述:
```yaml
schema:
type: object
properties:
status:
type: string
description: The status of the device
speed:
type: number
description: The speed of the device
required:
- status
- speed
additionalProperties: false
```

View File

@@ -0,0 +1,50 @@
# 添加新实验操作Protocol
`Uni-Lab`实验操作Protocol指的是**对实验有意义的单个完整动作**——加入某种液体多少量;萃取分液;洗涤仪器;机械+末端执行器等等,就像实验步骤文字书写的那样。
而这些对实验有意义的单个完整动作,**一般需要多个设备的协同**,或者同一设备连续动作,还依赖于他们的**物理连接关系(管道相连;机械臂可转运)**。`Protocol` 根据实验操作目标和设备物理连接关系,通过 `unilabos/compile` 中的“编译”过程产生硬件可执行的机器指令,并依次执行。
开发一个 `Protocol` 一般共需要修改6个文件
1.`unilabos_msgs/action` 中新建实验操作名和参数列表,如 `PumpTransfer.action`。一个 Action 定义由三个部分组成分别是目标Goal、结果Result和反馈Feedback之间使用 `---` 分隔:
```{literalinclude} ../../unilabos_msgs/action/PumpTransfer.action
```
2. 在 `unilabos_msgs/CMakeLists.txt` 中添加新定义的 action
因为在指令集中新建了指令,因此调试时需要编译,并在终端环境中加载临时路径:
```bash
cd unilabos_msgs
colcon build
source ./install/local_setup.sh
cd ..
```
调试成功后,发起 pull requestUni-Lab 的 CI/CD 系统会自动将新的指令集编译打包mamba执行升级即可永久生效
```bash
mamba update ros-humble-unilabos-msgs -c http://quetz.dp.tech:8088/get/unilab -c robostack-humble -c robostack-staging
```
3. 在 `unilabos/messages/__init__.py` 中添加 Pydantic 定义的实验操作名和参数列表
```{literalinclude} ../../unilabos/messages/__init__.py
:start-after: Start Protocols
:end-before: End Protocols
```
4. 在 `unilabos/compile` 中新建编译为机器指令的函数,函数入参为设备连接图 `G` 和实验操作参数。
```{literalinclude} ../../unilabos/compile/pump_protocol.py
:start-after: Pump protocol compilation
:end-before: End Protocols
```
5. 将该函数加入 `unilabos/compile/__init__.py` 的 `action_protocol_generators` 中:
```{literalinclude} ../../unilabos/compile/__init__.py
:start-after: Define
:end-before: End Protocols
```
6. 记得将新开发的 `Protocol` 添加至启动时的 `devices.json` 中。
```{literalinclude} ../../devices.json
:lines: 2-4
```

View File

@@ -0,0 +1,95 @@
# yaml注册表编写指南
`注册表的结构`
1. 顶层名称:每个设备的注册表以设备名称开头,例如 new_device。
2. class 字段:定义设备的模块路径和类型。
3. schema 字段:定义设备的属性模式,包括属性类型、描述和必需字段。
4. action_value_mappings 字段:定义设备支持的动作及其目标、反馈和结果。
`创建新的注册表教程`
1. 创建文件
在 devices 文件夹中创建一个新的 YAML 文件,例如 new_device.yaml。
2. 定义设备名称
在文件中定义设备的顶层名称例如new_device
3. 定义设备的类信息
添加设备的模块路径和类型:
```python
new_device: # 定义一个名为 linear_motion.grbl 的设备
class: # 定义设备的类信息
module: unilabos.devices_names.new_device:NewDeviceClass # 指定模块路径和类名
type: python # 指定类型为 Python 类
status_types:
```
4. 定义设备支持的动作
添加设备支持的动作及其目标、反馈和结果:
```python
action_value_mappings:
set_speed:
type: SendCmd
goal:
command: speed
feedback: {}
result:
success: success
```
`如何编写action_valve_mappings`
1. 在 devices 文件夹中的 YAML 文件中action_value_mappings 是用来定义设备支持的动作actions及其目标值goal、反馈值feedback和结果值result的映射规则。以下是规则和编写方法
```python
action_value_mappings:
<action_name>: # <action_name>:动作的名称
# start启动设备或某个功能。
# stop停止设备或某个功能。
# set_speed设置设备的速度。
# set_temperature设置设备的温度。
# move_to_position移动设备到指定位置。
# stir执行搅拌操作。
# heatchill执行加热或冷却操作。
# send_nav_task发送导航任务例如机器人导航
# set_timer设置设备的计时器。
# valve_open_cmd打开阀门。
# valve_close_cmd关闭阀门。
# execute_command_from_outer执行外部命令。
# push_to控制设备推送到某个位置例如机械爪
# move_through_points导航设备通过多个点。
type: <ActionType> # 动作的类型,表示动作的功能
# 根据动作的功能选择合适的类型:
# SendCmd发送简单命令。
# NavigateThroughPoses导航动作。
# SingleJointPosition设置单一关节的位置。
# Stir搅拌动作。
# HeatChill加热或冷却动作。
goal: # 定义动作的目标值映射,表示需要传递给设备的参数。
<goal_key>: <mapped_value> #确定设备需要的输入参数,并将其映射到设备的字段。
feedback: # 定义动作的反馈值映射,表示设备执行动作时返回的实时状态。
<feedback_key>: <mapped_value>
result: # 定义动作的结果值映射,表示动作完成后返回的最终结果。
<result_key>: <mapped_value>
```
6. 定义设备的属性模式
添加设备的属性模式,包括属性类型和描述:
```python
schema:
type: object
properties:
status:
type: string
description: The status of the device
speed:
type: number
description: The speed of the device
required:
- status
- speed
additionalProperties: false
```
# 写完yaml注册表后需要添加到哪些其他文件

View File

@@ -0,0 +1,330 @@
# 设备 Driver 开发
我们对设备 Driver 的定义,是一个 Python/C++/C# 类,类的方法可以用于获取传感器数据、执行设备动作、更新物料信息。它们经过 Uni-Lab 的通信中间件包装,就能成为高效分布式通信的设备节点。
因此,若已有设备的 SDK (Driver),可以直接 [添加进 Uni-Lab](add_device.md)。仅当没有 SDK (Driver) 时,请参考本章作开发。
## 有串口字符串指令集文档的设备Python 串口通信(常见 RS485, RS232, USB
开发方式:对照厂家给出的指令集文档,实现相应发送指令字符串的 python 函数。可参考 [注射泵串口驱动样例](https://github.com/TablewareBox/runze-syringe-pump)
## 常见工业通信协议ModbusRTU, TCP
Modbus 与 RS485、RS232 不一样的地方在于会有更多直接寄存器的读写以及涉及字节序转换Big Endian, Little Endian
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`
****
## 其他工业通信协议CANopen, Ethernet, OPCUA...
【敬请期待】
## 没有接口的老设备老软件:使用 PyWinAuto
**pywinauto**是一个 Python 库用于自动化Windows GUI操作。它可以模拟用户的鼠标点击、键盘输入、窗口操作等广泛应用于自动化测试、GUI自动化等场景。它支持通过两个后端进行操作
* **win32**后端适用于大多数Windows应用程序使用native Win32 API。pywinauto_recorder默认使用win32后端
* **uia**后端基于Microsoft UI Automation适用于较新的应用程序特别是基于WPF或UWP的应用程序。在win10上会有更全的目录有的窗口win32会识别不到
### windows平台安装pywinauto和pywinauto_recorder
直接安装会造成环境崩溃,需要下载并解压已经修改好的文件。
cd到对应目录执行安装
`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的计算器然后在ilab的环境中运行下面的代码片段可观察到得到结果通过这一案例你需要掌握的pywinauto用法
* 连接到指定进程
* 利用dump_tree查找需要的窗口
* 获取某个位置的信息
* 模拟点击
* 模拟输入
#### 代码学习
```Python
from pywinauto import Application
import time
from pywinauto.findwindows import ElementAmbiguousError
# 启动计算器应用
app = Application(backend='uia').connect(title="计算器")
# 连接到计算器窗口
window = app["计算器Dialog0"]
# 打印窗口控件树结构,帮助定位控件
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']
"""
# 通过控件路径可参考下一小节路径可以发现本文档第48-50行是我们需要定位的控件
# 本文档第16-18行为其自身即depth=1我们要定位的第48-50行是depth=3的控件从树来一级一级定位即可
# PyWinAuto为我们提供了非常便捷的取窗口方式在每3行dump的内容中第三行就是从上一级取出当前窗口的方式直接复制即可
# 这里注意到使用title="计算器", control_type="Window"进行匹配会出现两个因此指定found_index=1
sub_window = window.child_window(title="计算器", control_type="Window", found_index=1)
target_window = sub_window.child_window(auto_id="NavView", control_type="Custom")
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")
"""
# 观察到GroupBox控件并没有提供默认的child_window而list中的identifier均可作为best_match来索引
# ['标准GroupBox', 'GroupBox', 'GroupBox0', 'GroupBox1'] 这里选用第0项
group_box = target_window.child_window(best_match="标准GroupBox")
numpad = group_box.child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
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")
"""
# 获取按钮 '9'
button_9 = numpad.child_window(title="九", auto_id="num9Button", control_type="Button")
# 利用相同的办法,我们也可以找到增加和等于号的控件
std_calc_panel = group_box.child_window(title="标准运算符", auto_id="StandardOperators", control_type="Group")
equal_operation = std_calc_panel.child_window(title="等于", auto_id="equalButton", control_type="Button")
# 模拟点击按钮 '9'
button_9.click_input()
# 键入https://github.com/pywinauto/pywinauto/blob/atspi/pywinauto/windows/keyboard.py
# 模拟输入 '加号' 和 数字9
window.type_keys("{VK_ADD}9")
# 等于
equal_operation.click_input()
# 获取计算结果文本(显示在计算器窗口的文本框中)
result = group_box.child_window(auto_id="CalculatorResults", control_type="Text").window_text()
print(f"计算结果:{result[4:]}") # 应当得到结果18
```
#### 依据像素判定状态
```Python
# 有时你需要根据窗口的颜色判断是否可用是否正在运行可以使用pyautogui来实现这一功能
# pyautogui需要对应在环境中进行安装
point_x = int(control_view.rectangle().left + control_view.rectangle().width() * 0.15)
point_y = 15 + control_view.rectangle().top
r, g, b = pyautogui.pixel(point_x, point_y)
```
### pywinauto_recorder
pywinauto_recorder是一个配合 pywinauto 使用的工具,用于录制用户的操作,并生成相应的 pywinauto 脚本。这对于一些暂时无法直接调用DLL的函数并且需要模拟用户操作的场景非常有用。同时可以省去仅用pywinauto的一些查找UI步骤。
#### 运行尝试
请参照 上手尝试-环境创建-3 开启pywinauto_recorder
例如我们这里先启动一个windows自带的计算器软件
![calculator_01](image/device_driver/calculator_01.png)
在录制状态下点击数字键盘的“9”随后退出录制得到下图运行的文件。
![calculator_02](image/device_driver/calculator_02.png)
```Python
# encoding: utf-8
from pywinauto_recorder.player import *
with UIPath(u"计算器||Window"):
with UIPath(u"计算器||Window->||Custom->||Group->数字键盘||Group"):
click(u"九||Button")
```
执行该python脚本可以观察到新开启的计算器被点击了数字9
![calculator_03](image/device_driver/calculator_03.png)
### `dump_tree`详解
`dump_tree`方法用于打印控件树结构,帮助我们快速了解应用程序窗口中的控件层级,尤其是在自动化测试或脚本开发中,识别控件非常重要。
```Python
window.dump_tree(depth=[int类型数字], filename=None)
# 打印当前窗口及其子控件的树结构
# 在debug的过程中如果需要查找某一控件可以通过depth指定为4~5利用搜索查看是哪个独立控件
# 指定filename后将保存到对应目录文件中
```
输出会列出窗口的各个控件及其子控件,显示每个控件的属性(如标题、类型等)。
```Python
"""
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进行解读
2~4行为当前对象的窗口
* 第2行分别是窗体的类型 `GroupBox`,窗体的题目 `数字键盘`,窗体的矩形区域坐标,对应的是屏幕上的位置(左、上、右、下)
* 第3行是 `['GroupBox', '数字键盘', '数字键盘GroupBox']`,为控件的标识符列表,可以选择任意一个,使用 `child_window(best_match="标识符")`来获取该窗口
* 第4行是获取该控件的方法请注意该方法不能保证获取唯一`title`如果是变化的,也需要删除 `title`参数
6~8行为当前对象窗口所包含的子窗口信息信息类型对应2~4行
### 窗口获取注意事项
1. 在 `child_window`的时候,并不会立刻报错,只有在执行窗口的信息获取时才会调用,查询窗口是否存在,因此要想确定 `child_window`是否正确,可以调用子窗口对象的属性 `element_info`,来保证窗口存在

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

9
docs/index.md Normal file
View File

@@ -0,0 +1,9 @@
# Uni-Lab 项目文档
欢迎来到项目文档的首页!
```{toctree}
:maxdepth: 3
intro.md
```

44
docs/intro.md Normal file
View File

@@ -0,0 +1,44 @@
欢迎来到项目文档的首页!
## 核心概念
```{toctree}
:maxdepth: 2
concepts/overview.md
concepts/01-communication-instruction.md
concepts/02-topology-and-chemputer-compile.md
```
## **用户指南**
本指南将带你了解如何使用项目的功能。
```{toctree}
:maxdepth: 2
user_guide/installation.md
user_guide/configuration.md
user_guide/launch.md
boot_examples/index.md
```
## 开发者指南
```{toctree}
:maxdepth: 2
developer_guide/device_driver
developer_guide/add_device
developer_guide/add_action
developer_guide/actions
developer_guide/add_protocol
```
## 接口文档
```{toctree}
:maxdepth: 2
apidocs/unilabos
```

35
docs/make.bat Normal file
View File

@@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

View File

@@ -0,0 +1,120 @@
# Uni-Lab 配置指南
Uni-Lab支持通过Python配置文件进行灵活的系统配置。本指南将帮助您理解配置选项并设置您的Uni-Lab环境。
## 配置文件格式
Uni-Lab支持Python格式的配置文件它比YAML或JSON提供更多的灵活性包括支持注释、条件逻辑和复杂数据结构。
### 基本配置示例
一个典型的配置文件包含以下部分:
```python
#!/usr/bin/env python
# coding=utf-8
"""Uni-Lab 配置文件"""
from dataclasses import dataclass
# 配置类定义
@dataclass
class MQConfig:
"""MQTT 配置类"""
lab_id: str = "YOUR_LAB_ID"
# 更多配置...
# 其他配置类...
```
## 配置选项说明
### MQTT配置 (MQConfig)
MQTT配置用于连接消息队列服务是Uni-Lab与云端通信的主要方式。
```python
@dataclass
class MQConfig:
"""MQTT 配置类"""
lab_id: str = "7AAEDBEA" # 实验室唯一标识
instance_id: str = "mqtt-cn-instance"
access_key: str = "your-access-key"
secret_key: str = "your-secret-key"
group_id: str = "GID_labs"
broker_url: str = "mqtt-cn-instance.mqtt.aliyuncs.com"
port: int = 8883
# 可以直接提供证书文件路径
ca_file: str = "/path/to/ca.pem"
cert_file: str = "/path/to/cert.pem"
key_file: str = "/path/to/key.pem"
# 或者直接提供证书内容
ca_content: str = ""
cert_content: str = ""
key_content: str = ""
```
#### 证书配置
MQTT连接支持两种方式配置证书
1. **文件路径方式**(推荐):指定证书文件的路径,系统会自动读取文件内容
2. **直接内容方式**:直接在配置中提供证书内容
推荐使用文件路径方式,便于证书的更新和管理。
### HTTP客户端配置 (HTTPConfig)
即将开放 Uni-Lab 云端实验室。
### ROS模块配置 (ROSConfig)
配置ROS消息转换器需要加载的模块
```python
@dataclass
class ROSConfig:
"""ROS模块配置"""
modules: list = None
def __post_init__(self):
if self.modules is None:
self.modules = [
"std_msgs.msg",
"geometry_msgs.msg",
"control_msgs.msg",
"control_msgs.action",
"nav2_msgs.action",
"unilabos_msgs.msg",
"unilabos_msgs.action",
]
```
您可以根据需要添加其他ROS模块。
### 其他配置选项
- **OSSUploadConfig**: 对象存储上传配置
## 如何使用配置文件
启动Uni-Lab时通过`--config`参数指定配置文件路径:
```bash
unilab --config path/to/your/config.py
```
## 环境变量覆盖
某些配置项可以通过环境变量进行覆盖,这在不同环境部署时特别有用:
```bash
# 设置环境变量覆盖配置
export UNILAB_LAB_ID="YOUR_LAB_ID"
export UNILAB_MQTT_BROKER="mqtt-broker-address"
# 启动Uni-Lab
python -m unilabos.app.main --config path/to/your/config.py
```

View File

@@ -0,0 +1,24 @@
# **Uni-Lab 安装**
请先 `git clone` 本仓库,随后按照以下步骤安装项目:
`Uni-Lab` 建议您采用 `mamba` 管理环境。若需从头建立 `Uni-Lab` 的运行依赖环境,请执行
```shell
mamba env create -f unilabos-<YOUR_OS>.yaml
mamba activate ilab
```
其中 `YOUR_OS` 是您的操作系统,可选值 `win64`, `linux-64`, `osx-64`, `osx-arm64`
若需将依赖安装进当前环境,请执行
```shell
conda env update --file unilabos-<YOUR_OS>.yml
```
随后,可在本仓库安装 `unilabos` 的开发版:
```shell
pip install .
```

77
docs/user_guide/launch.md Normal file
View File

@@ -0,0 +1,77 @@
# Uni-Lab 启动
安装完毕后,可以通过 `unilab` 命令行启动:
```bash
Start Uni-Lab Edge server.
options:
-h, --help show this help message and exit
-g GRAPH, --graph GRAPH
Physical setup graph.
-d DEVICES, --devices DEVICES
Devices config file.
-r RESOURCES, --resources RESOURCES
Resources config file.
-c CONTROLLERS, --controllers CONTROLLERS
Controllers config file.
--registry_path REGISTRY_PATH
Path to the registry
--backend {ros,simple,automancer}
Choose the backend to run with: 'ros', 'simple', or 'automancer'.
--app_bridges APP_BRIDGES [APP_BRIDGES ...]
Bridges to connect to. Now support 'mqtt' and 'fastapi'.
--without_host Run the backend as slave (without host).
--config CONFIG Configuration file path for system settings
```
## 使用配置文件
Uni-Lab支持使用Python格式的配置文件进行系统设置。通过 `--config` 参数指定配置文件路径:
```bash
# 使用配置文件启动
unilab --config path/to/your/config.py
```
配置文件包含MQTT、HTTP、ROS等系统设置。有关配置文件的详细信息请参阅[配置指南](configuration.md)。
## 初始化信息来源
启动 Uni-Lab 时,可以选用两种方式之一配置实验室设备、耗材、通信、控制逻辑:
### 1. 组态&拓扑图
使用 `-g` 时,组态&拓扑图应包含实验室所有信息,详见{ref}`graph`。目前支持 graphml 和 node-link json 两种格式。格式可参照 `tests/experiments` 下的启动文件。
### 2. 分别指定设备、耗材、控制逻辑
分别使用 `-d, -r, -c` 依次传入设备组态配置、耗材列表、控制逻辑。
可参照 `devices.json``resources.json`
不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry` 加入 `<your-registry-path>/{devices,device_comms,resources}`
## 通信中间件 `--backend`
目前 Uni-Lab 仅支持 ros2 作为通信中间件。
## 端云桥接 `--app_bridges`
目前 Uni-Lab 提供 FastAPI (http), MQTT 两种端云通信方式。其中默认 MQTT 负责端对云状态同步和云对端任务下发FastAPI 负责端对云物料更新。
## 分布式组网
启动 Uni-Lab 时,加入 `--without_host` 将作为从站,不加将作为主站,主站 (host) 持有物料修改权以及对云端的通信。局域网内分别启动的 Uni-Lab 主站/从站将自动组网,互相能访问所有设备状态、传感器信息并发送指令。
## 完整启动示例
以下是一些常用的启动命令示例:
```bash
# 使用配置文件和组态图启动
unilab -g path/to/graph.json
# 使用配置文件和分离的设备/资源文件启动
unilab -d devices.json -r resources.json
```

22
package.xml Normal file
View File

@@ -0,0 +1,22 @@
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>unilabos</name>
<version>0.0.0</version>
<description>ROS2 package for unilabos server</description>
<maintainer email="changjh@pku.edu.cn">changjh</maintainer>
<license>TODO: License declaration</license>
<build_depend>action_msgs</build_depend>
<exec_depend>action_msgs</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>
<test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend>
<test_depend>ament_pep257</test_depend>
<test_depend>python3-pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>

View File

@@ -0,0 +1,47 @@
gazebo:
- '11'
libpqxx:
- 6
numpy:
- 1.26.*
cdt_name: # [linux]
- cos7 # [linux]
python:
- 3.11.* *_cpython
python_impl:
- cpython
# Project overrides
macos_min_version: # [osx and x86_64]
- 10.14 # [osx and x86_64]
macos_machine: # [osx]
- x86_64-apple-darwin13.4.0 # [osx and x86_64]
- arm64-apple-darwin20.0.0 # [osx and arm64]
MACOSX_DEPLOYMENT_TARGET: # [osx]
- 11.0 # [osx and arm64]
- 10.14 # [osx and x86_64]
CONDA_BUILD_SYSROOT:
- /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk # [osx and arm64]
# fix build metadata, needed for mapviz and moveit-core
replacements:
all_replacements:
- tag: 'pkg-config build metadata'
glob_patterns:
- '*.pc'
regex_re: '(?:-L|-I)?\"?([^;\s]+\/sysroot\/)'
replacement_re: '$(CONDA_BUILD_SYSROOT_S)'
regex_rg: '([^;\s"]+/sysroot/)'
- tag: 'CMake build metadata'
glob_patterns:
- '*.cmake'
regex_re: '([^;\s"]+/sysroot)'
replacement_re: '$ENV{CONDA_BUILD_SYSROOT}'
- tag: 'CMake build metadata OSX'
glob_patterns:
- '*.cmake'
regex_re: '([^;\s"]+/MacOSX\d*\.?\d*\.sdk)'
replacement_re: '$ENV{CONDA_BUILD_SYSROOT}'

View File

@@ -0,0 +1,41 @@
:: Generated by vinca http://github.com/RoboStack/vinca.
:: DO NOT EDIT!
setlocal EnableDelayedExpansion
set "PYTHONPATH=%LIBRARY_PREFIX%\lib\site-packages;%SP_DIR%"
:: MSVC is preferred.
set CC=cl.exe
set CXX=cl.exe
rd /s /q build
mkdir build
pushd build
:: set "CMAKE_GENERATOR=Ninja"
:: try to fix long paths issues by using default generator
set "CMAKE_GENERATOR=Visual Studio %VS_MAJOR% %VS_YEAR%"
set "SP_DIR_FORWARDSLASHES=%SP_DIR:\=/%"
set PYTHON="%PREFIX%\python.exe"
cmake ^
-G "%CMAKE_GENERATOR%" ^
-DCMAKE_INSTALL_PREFIX=%LIBRARY_PREFIX% ^
-DCMAKE_BUILD_TYPE=Release ^
-DCMAKE_INSTALL_SYSTEM_RUNTIME_LIBS_SKIP=True ^
-DPYTHON_EXECUTABLE=%PYTHON% ^
-DPython_EXECUTABLE=%PYTHON% ^
-DPython3_EXECUTABLE=%PYTHON% ^
-DSETUPTOOLS_DEB_LAYOUT=OFF ^
-DBUILD_SHARED_LIBS=ON ^
-DBUILD_TESTING=OFF ^
-DCMAKE_OBJECT_PATH_MAX=255 ^
-DPYTHON_INSTALL_DIR=%SP_DIR_FORWARDSLASHES% ^
--compile-no-warning-as-error ^
%SRC_DIR%\%PKG_NAME%\src\work
if errorlevel 1 exit 1
cmake --build . --config Release --target install
if errorlevel 1 exit 1

View File

@@ -0,0 +1,71 @@
# Generated by vinca http://github.com/RoboStack/vinca.
# DO NOT EDIT!
rm -rf build
mkdir build
cd build
# necessary for correctly linking SIP files (from python_qt_bindings)
export LINK=$CXX
if [[ "$CONDA_BUILD_CROSS_COMPILATION" != "1" ]]; then
PYTHON_EXECUTABLE=$PREFIX/bin/python
PKG_CONFIG_EXECUTABLE=$PREFIX/bin/pkg-config
OSX_DEPLOYMENT_TARGET="10.15"
else
PYTHON_EXECUTABLE=$BUILD_PREFIX/bin/python
PKG_CONFIG_EXECUTABLE=$BUILD_PREFIX/bin/pkg-config
OSX_DEPLOYMENT_TARGET="11.0"
fi
echo "USING PYTHON_EXECUTABLE=${PYTHON_EXECUTABLE}"
echo "USING PKG_CONFIG_EXECUTABLE=${PKG_CONFIG_EXECUTABLE}"
export ROS_PYTHON_VERSION=`$PYTHON_EXECUTABLE -c "import sys; print('%i.%i' % (sys.version_info[0:2]))"`
echo "Using Python ${ROS_PYTHON_VERSION}"
# Fix up SP_DIR which for some reason might contain a path to a wrong Python version
FIXED_SP_DIR=$(echo $SP_DIR | sed -E "s/python[0-9]+\.[0-9]+/python$ROS_PYTHON_VERSION/")
echo "Using site-package dir ${FIXED_SP_DIR}"
# see https://github.com/conda-forge/cross-python-feedstock/issues/24
if [[ "$CONDA_BUILD_CROSS_COMPILATION" == "1" ]]; then
find $PREFIX/lib/cmake -type f -exec sed -i "s~\${_IMPORT_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~${BUILD_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~g" {} + || true
find $PREFIX/share/rosidl* -type f -exec sed -i "s~$PREFIX/lib/python${ROS_PYTHON_VERSION}/site-packages~${BUILD_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~g" {} + || true
find $PREFIX/share/rosidl* -type f -exec sed -i "s~\${_IMPORT_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~${BUILD_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~g" {} + || true
find $PREFIX/lib/cmake -type f -exec sed -i "s~message(FATAL_ERROR \"The imported target~message(WARNING \"The imported target~g" {} + || true
fi
if [[ $target_platform =~ linux.* ]]; then
export CFLAGS="${CFLAGS} -D__STDC_FORMAT_MACROS=1"
export CXXFLAGS="${CXXFLAGS} -D__STDC_FORMAT_MACROS=1"
fi;
# Needed for qt-gui-cpp ..
if [[ $target_platform =~ linux.* ]]; then
ln -s $GCC ${BUILD_PREFIX}/bin/gcc
ln -s $GXX ${BUILD_PREFIX}/bin/g++
fi;
cmake \
-G "Ninja" \
-DCMAKE_INSTALL_PREFIX=$PREFIX \
-DCMAKE_PREFIX_PATH=$PREFIX \
-DAMENT_PREFIX_PATH=$PREFIX \
-DCMAKE_INSTALL_LIBDIR=lib \
-DCMAKE_BUILD_TYPE=Release \
-DPYTHON_EXECUTABLE=$PYTHON_EXECUTABLE \
-DPython_EXECUTABLE=$PYTHON_EXECUTABLE \
-DPython3_EXECUTABLE=$PYTHON_EXECUTABLE \
-DPython3_FIND_STRATEGY=LOCATION \
-DPKG_CONFIG_EXECUTABLE=$PKG_CONFIG_EXECUTABLE \
-DPYTHON_INSTALL_DIR=$FIXED_SP_DIR \
-DSETUPTOOLS_DEB_LAYOUT=OFF \
-DCATKIN_SKIP_TESTING=$SKIP_TESTING \
-DCMAKE_INSTALL_SYSTEM_RUNTIME_LIBS_SKIP=True \
-DBUILD_SHARED_LIBS=ON \
-DBUILD_TESTING=OFF \
-DCMAKE_OSX_DEPLOYMENT_TARGET=$OSX_DEPLOYMENT_TARGET \
--compile-no-warning-as-error \
$SRC_DIR/$PKG_NAME/src/work
cmake --build . --config Release --target install

View File

@@ -0,0 +1,61 @@
package:
name: ros-humble-unilabos-msgs
version: 0.8.0
source:
path: ../../unilabos_msgs
folder: ros-humble-unilabos-msgs/src/work
build:
script:
sel(win): bld_ament_cmake.bat
sel(unix): build_ament_cmake.sh
number: 5
about:
home: https://www.ros.org/
license: BSD-3-Clause
summary: |
Robot Operating System
extra:
recipe-maintainers:
- ros-forge
requirements:
build:
- "{{ compiler('cxx') }}"
- "{{ compiler('c') }}"
- sel(linux64): sysroot_linux-64 2.17
- ninja
- setuptools
- sel(unix): make
- sel(unix): coreutils
- sel(osx): tapi
- sel(build_platform != target_platform): pkg-config
- cmake
- cython
- sel(win): vs2022_win-64
- sel(build_platform != target_platform): python
- sel(build_platform != target_platform): cross-python_{{ target_platform }}
- sel(build_platform != target_platform): numpy
host:
- numpy
- pip
- sel(build_platform == target_platform): pkg-config
- robostack-staging::ros-humble-action-msgs
- robostack-staging::ros-humble-ament-cmake
- robostack-staging::ros-humble-ament-lint-auto
- robostack-staging::ros-humble-ament-lint-common
- robostack-staging::ros-humble-ros-environment
- robostack-staging::ros-humble-ros-workspace
- robostack-staging::ros-humble-rosidl-default-generators
- robostack-staging::ros-humble-std-msgs
- robostack-staging::ros-humble-geometry-msgs
- robostack-staging::ros2-distro-mutex=0.6.*
run:
- robostack-staging::ros-humble-action-msgs
- robostack-staging::ros-humble-ros-workspace
- robostack-staging::ros-humble-rosidl-default-runtime
- robostack-staging::ros-humble-std-msgs
- robostack-staging::ros-humble-geometry-msgs
- robostack-staging::ros2-distro-mutex=0.6.*
- sel(osx and x86_64): __osx >={{ MACOSX_DEPLOYMENT_TARGET|default('10.14') }}

View File

@@ -0,0 +1,15 @@
import os
import shutil
for item in os.listdir("../.."):
if item.startswith("."):
continue
if item.endswith(".bat"):
continue
if item in ("setup.py", "unilabos", "config"):
continue
print("****", item)
if os.path.isfile(item) or os.path.islink(item):
os.remove(item)
elif os.path.isdir(item):
shutil.rmtree(item)
print(os.listdir("../.."))

View File

@@ -0,0 +1,23 @@
package:
name: unilabos
version: "0.8.0"
source:
path: ../..
build:
noarch: python
script: |
{{ PYTHON }} -m pip install . --no-deps --ignore-installed -vv
# {{ PYTHON }} clean_build_dir.py
requirements:
host:
- python
- pip
run:
- python
test:
imports:
- unilabos

4
setup.cfg Normal file
View File

@@ -0,0 +1,4 @@
[develop]
script_dir=$base/lib/unilabos
[install]
install_scripts=$base/lib/unilabos

32
setup.py Normal file
View File

@@ -0,0 +1,32 @@
from setuptools import setup, find_packages
from glob import glob
import os
package_name = 'unilabos'
setup(
name=package_name,
version='0.8.0',
packages=find_packages(),
# data_files=[
# ('share/ament_index/resource_index/packages',
# ['resource/' + package_name]),
# ('share/' + package_name, ['package.xml']),
# # (os.path.join('share', package_name, 'launch'), glob('launch/*.launch.py')),
# # (os.path.join('share', package_name, 'urdf'), glob('urdf/*')),
# # (os.path.join('share', package_name, 'meshes'), glob('meshes/*')),
# # (os.path.join('share', package_name, 'config'), glob('config/*'))
# ],
install_requires=['setuptools'],
zip_safe=True,
maintainer='Junhan Chang',
maintainer_email='changjh@pku.edu.cn',
description='TODO: Package description',
license='TODO: License declaration',
tests_require=['pytest'],
entry_points={
'console_scripts': [
"unilab = unilabos.app.main:main",
],
},
)

View File

@@ -0,0 +1,846 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!--Created by yFiles for HTML 2.6.0.4-->
<graphml xsi:schemaLocation="http://graphml.graphdrawing.org/xmlns http://www.yworks.com/xml/schema/graphml.html/2.0/ygraphml.xsd " xmlns="http://graphml.graphdrawing.org/xmlns" xmlns:demostyle2="http://www.yworks.com/yFilesHTML/demos/FlatDemoStyle/2.0" xmlns:demostyle="http://www.yworks.com/yFilesHTML/demos/FlatDemoStyle/1.0" xmlns:icon-style="http://www.yworks.com/yed-live/icon-style/1.0" xmlns:bpmn="http://www.yworks.com/xml/yfiles-bpmn/2.0" xmlns:demotablestyle="http://www.yworks.com/yFilesHTML/demos/FlatDemoTableStyle/1.0" xmlns:uml="http://www.yworks.com/yFilesHTML/demos/UMLDemoStyle/1.0" xmlns:GraphvizNodeStyle="http://www.yworks.com/yFilesHTML/graphviz-node-style/1.0" xmlns:Vue2jsNodeStyle="http://www.yworks.com/demos/yfiles-vuejs-node-style/1.0" xmlns:Vue3jsNodeStyle="http://www.yworks.com/demos/yfiles-vue-node-style/3.0" xmlns:explorer-style="http://www.yworks.com/data-explorer/1.0" xmlns:y="http://www.yworks.com/xml/yfiles-common/3.0" xmlns:x="http://www.yworks.com/xml/yfiles-common/markup/3.0" xmlns:yjs="http://www.yworks.com/xml/yfiles-for-html/2.0/xaml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<key id="d0" for="node" attr.type="int" attr.name="zOrder" y:attr.uri="http://www.yworks.com/xml/yfiles-z-order/1.0/zOrder"/>
<key id="d1" for="node" attr.type="boolean" attr.name="Expanded" y:attr.uri="http://www.yworks.com/xml/yfiles-common/2.0/folding/Expanded">
<default>true</default>
</key>
<key id="d2" for="node" attr.type="string" attr.name="url"/>
<key id="d3" for="node" attr.type="string" attr.name="description"/>
<key id="d4" for="node" attr.type="string" attr.name="NodeLabels" y:attr.uri="http://www.yworks.com/xml/yfiles-common/2.0/NodeLabels"/>
<key id="d5" for="node" attr.type="string" attr.name="NodeGeometry" y:attr.uri="http://www.yworks.com/xml/yfiles-common/2.0/NodeGeometry"/>
<key id="d6" for="all" attr.name="UserTags" y:attr.uri="http://www.yworks.com/xml/yfiles-common/2.0/UserTags"/>
<key id="d7" for="node" attr.name="NodeStyle" y:attr.uri="http://www.yworks.com/xml/yfiles-common/2.0/NodeStyle"/>
<key id="d8" for="node" attr.name="NodeViewState" y:attr.uri="http://www.yworks.com/xml/yfiles-common/2.0/folding/1.1/NodeViewState"/>
<key id="d9" for="edge" attr.type="string" attr.name="url"/>
<key id="d10" for="edge" attr.type="string" attr.name="description"/>
<key id="d11" for="edge" attr.type="string" attr.name="EdgeLabels" y:attr.uri="http://www.yworks.com/xml/yfiles-common/2.0/EdgeLabels"/>
<key id="d12" for="edge" attr.type="string" attr.name="EdgeGeometry" y:attr.uri="http://www.yworks.com/xml/yfiles-common/2.0/EdgeGeometry"/>
<key id="d13" for="edge" attr.type="string" attr.name="EdgeStyle" y:attr.uri="http://www.yworks.com/xml/yfiles-common/2.0/EdgeStyle"/>
<key id="d14" for="edge" attr.type="string" attr.name="EdgeViewState" y:attr.uri="http://www.yworks.com/xml/yfiles-common/2.0/folding/1.1/EdgeViewState"/>
<key id="d19" attr.name="SharedData" y:attr.uri="http://www.yworks.com/xml/yfiles-common/2.0/SharedData"/>
<data key="d19">
<y:SharedData>
<yjs:SolidColorFill x:Key="18" color="LightGrey"/>
<yjs:GroupNodeStyle x:Key="19" tabWidth="30" tabHeight="20" tabInset="3" tabPosition="TOP_TRAILING" groupIcon="MINUS" iconForegroundFill="#FF0B7189" iconOffset="2" minimumContentAreaSize="0,0" hitTransparentContentArea="true" tabBackgroundFill="#FF0B7189" contentAreaFill="#FFFFFFFF" tabFill="#FF9DC6D0" cssClass="y-group-node">
<yjs:GroupNodeStyle.stroke>
<yjs:Stroke fill="#FF0B7189" lineCap="SQUARE" thickness="2"/>
</yjs:GroupNodeStyle.stroke>
</yjs:GroupNodeStyle>
<y:FreeEdgeLabelModel x:Key="20"/>
<yjs:SolidColorFill x:Key="21" color="LightGrey"/>
<yjs:Arrow x:Key="22" type="NONE"/>
<yjs:SolidColorFill x:Key="23" color="LightGrey"/>
<yjs:SolidColorFill x:Key="24" color="#FF0E0E10"/>
<yjs:SolidColorFill x:Key="25" color="#FFA72920"/>
<yjs:SolidColorFill x:Key="26" color="#FFF6B600"/>
<yjs:SolidColorFill x:Key="27" color="#FF61993B"/>
<yjs:SolidColorFill x:Key="28" color="#FF7E4B26"/>
<yjs:SolidColorFill x:Key="29" color="#FFF67828"/>
<yjs:SolidColorFill x:Key="30" color="#FF0E0E10"/>
<yjs:SolidColorFill x:Key="31" color="#FFF6B600"/>
<yjs:SolidColorFill x:Key="32" color="#FFA72920"/>
<yjs:SolidColorFill x:Key="33" color="Black"/>
<yjs:SolidColorFill x:Key="34" color="Black"/>
<yjs:SolidColorFill x:Key="35" color="#FF0E0E10"/>
<yjs:SolidColorFill x:Key="36" color="#FFF6B600"/>
<yjs:SolidColorFill x:Key="37" color="#FF61993B"/>
<yjs:SolidColorFill x:Key="38" color="Black"/>
<yjs:DefaultLabelStyle x:Key="39" horizontalTextAlignment="CENTER" font="{y:GraphMLReference 9}" autoFlip="false" textFill="{y:GraphMLReference 8}"/>
<yjs:Stroke x:Key="40" fill="{y:GraphMLReference 18}" miterLimit="1.45"/>
<yjs:Arrow x:Key="41" stroke="#FFD3D3D3" fill="{y:GraphMLReference 18}"/>
<yjs:PolylineEdgeStyle x:Key="42" stroke="{y:GraphMLReference 40}" targetArrow="{y:GraphMLReference 41}" sourceArrow="{y:GraphMLReference 22}"/>
</y:SharedData>
</data>
<graph id="G" edgedefault="directed">
<data key="d6">
<y:Json>{"version":"2.0.0","origin":"yed-live","theme":{"name":"light","version":"1.0.0"},"layout":"layout-hierarchic","config":{"p_useDrawingAsSketch":false,"p_selectedElementsIncrementally":false,"p_nodeToNodeDistance":30,"p_automaticEdgeGroupingEnabled":false,"p_considerNodeLabels":true,"p_edgeLabeling":1,"p_orientation":0,"p_edgeRouting":0}}</y:Json>
</data>
<node id="n0">
<data key="d0">12</data>
<data key="d4">flask_separator_bottom</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 1}"/>
</data>
</node>
<node id="n1">
<data key="d0">13</data>
<data key="d4">flask_NH4Cl</data>
<data key="d5">
<y:RectD X="568.1111111111111" Y="428" Width="100" Height="141"/>
</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 1}"/>
</data>
</node>
<node id="n2">
<data key="d0">14</data>
<data key="d4">flask_CH2Cl2</data>
<data key="d5">
<y:RectD X="430.4087301587302" Y="428" Width="100" Height="141"/>
</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 1}"/>
</data>
</node>
<node id="n3">
<data key="d0">15</data>
<data key="d4">flask_acetone</data>
<data key="d5">
<y:RectD X="295.36944444444447" Y="428" Width="100" Height="141"/>
</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 1}"/>
</data>
</node>
<node id="n4">
<data key="d0">17</data>
<data key="d4">waste_workup</data>
<data key="d5">
<y:RectD X="1587.703373015873" Y="1172.5" Width="100" Height="100"/>
</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 2}"/>
</data>
</node>
<node id="n5">
<data key="d0">26</data>
<data key="d4">flask_H2O</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 1}"/>
</data>
</node>
<node id="n6">
<data key="d0">27</data>
<data key="d4">flask_NaHCO3</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 1}"/>
</data>
</node>
<node id="n7">
<data key="d0">32</data>
<data key="d4">reactor_reactor</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 3}"/>
</data>
</node>
<node id="n8">
<data key="d0">33</data>
<data key="d4">flask_rv_bottom</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 1}"/>
</data>
</node>
<node id="n9">
<data key="d0">34</data>
<data key="d4">rotavap</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 4}"/>
</data>
</node>
<node id="n10">
<data key="d0">35</data>
<data key="d4">flask_holding</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 1}"/>
</data>
</node>
<node id="n11">
<data key="d0">36</data>
<data key="d4">flask_separator_top</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 5}"/>
</data>
</node>
<node id="n12">
<data key="d0">37</data>
<data key="d4">flask_separator_bottom</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 1}"/>
</data>
</node>
<node id="n13">
<data key="d0">41</data>
<data key="d4">flask_eluent</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 1}"/>
</data>
</node>
<node id="n14">
<data key="d0">42</data>
<data key="d4">column_top</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 6}"/>
</data>
</node>
<node id="n15">
<data key="d0">43</data>
<data key="d4">flask_column_bottom</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 1}"/>
</data>
</node>
<node id="n16">
<data key="d0">44</data>
<data key="d4">flask_grinard</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 1}"/>
</data>
</node>
<node id="n17">
<data key="d0">45</data>
<data key="d4"><![CDATA[dry air]]></data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 6}"/>
</data>
</node>
<node id="n18">
<data key="d0">46</data>
<data key="d4">dry_column_top</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 6}"/>
</data>
</node>
<node id="n19">
<data key="d0">47</data>
<data key="d4">flask_dry_column_bottom</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 1}"/>
</data>
</node>
<node id="n20">
<data key="d0">48</data>
<data key="d4">valve_ext</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 16}"/>
</data>
</node>
<!-- <node id="n21">
<data key="d0">49</data>
<data key="d7">
<y:GraphMLReference ResourceKey="19"/>
</data>
<graph id="n21:" edgedefault="directed"> -->
<node id="n21::n0">
<data key="d0">0</data>
<data key="d4">pump_reagents_1</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 17}"/>
</data>
</node>
<node id="n21::n1">
<data key="d0">1</data>
<data key="d4">valve_reagents_1</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 16}"/>
</data>
</node>
<edge id="n21::e0" source="n21::n0" target="n21::n1" sourceport="p0" targetport="p0">
<data key="d11">-1</data>
<data key="d13">
<yjs:PolylineEdgeStyle stroke="{y:GraphMLReference 40}" targetArrow="{y:GraphMLReference 41}" sourceArrow="{y:GraphMLReference 22}"/>
</data>
</edge>
<!-- </graph>
</node> -->
<!-- <node id="n22">
<data key="d0">50</data>
<data key="d7">
<y:GraphMLReference ResourceKey="19"/>
</data>
<graph id="n22:" edgedefault="directed"> -->
<node id="n22::n0">
<data key="d0">0</data>
<data key="d4">pump_workup</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 17}"/>
</data>
</node>
<node id="n22::n1">
<data key="d0">1</data>
<data key="d4">valve_workup</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 16}"/>
</data>
</node>
<edge id="n22::e0" source="n22::n0" target="n22::n1" sourceport="p0" targetport="p0">
<data key="d11">-1</data>
<data key="d13">
<yjs:PolylineEdgeStyle sourceArrow="{y:GraphMLReference 22}">
<yjs:PolylineEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 21}" miterLimit="1.45"/>
</yjs:PolylineEdgeStyle.stroke>
<yjs:PolylineEdgeStyle.targetArrow>
<yjs:Arrow stroke="#FFD3D3D3" fill="{y:GraphMLReference 21}"/>
</yjs:PolylineEdgeStyle.targetArrow>
</yjs:PolylineEdgeStyle>
</data>
</edge>
<!-- </graph>
</node> -->
<!-- <node id="n23">
<data key="d0">51</data>
<data key="d7">
<y:GraphMLReference ResourceKey="19"/>
</data>
<graph id="n23:" edgedefault="directed"> -->
<node id="n23::n0">
<data key="d0">0</data>
<data key="d4">pump_column</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 17}"/>
</data>
</node>
<node id="n23::n1">
<data key="d0">1</data>
<data key="d4">valve_column</data>
<data key="d7">
<yjs:ImageNodeStyle image="{y:GraphMLReference 16}"/>
</data>
</node>
<edge id="n23::e0" source="n23::n0" target="n23::n1" sourceport="p0" targetport="p0">
<data key="d11">-1</data>
<data key="d13">
<yjs:PolylineEdgeStyle sourceArrow="{y:GraphMLReference 22}">
<yjs:PolylineEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 23}" miterLimit="1.45"/>
</yjs:PolylineEdgeStyle.stroke>
<yjs:PolylineEdgeStyle.targetArrow>
<yjs:Arrow stroke="#FFD3D3D3" fill="{y:GraphMLReference 23}"/>
</yjs:PolylineEdgeStyle.targetArrow>
</yjs:PolylineEdgeStyle>
</data>
</edge>
<!-- </graph>
</node> -->
<edge id="e0" source="n21::n1" target="n0" sourceport="p2" targetport="p0">
<data key="d11">7</data>
<data key="d12">
<x:List>
<y:Bend Location="625.6111111111111,304"/>
<y:Bend Location="85,304"/>
</x:List>
</data>
<data key="d13">
<yjs:PolylineEdgeStyle sourceArrow="{y:GraphMLReference 22}">
<yjs:PolylineEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 24}" miterLimit="1.45"/>
</yjs:PolylineEdgeStyle.stroke>
<yjs:PolylineEdgeStyle.targetArrow>
<yjs:Arrow stroke="#FF0E0E10" fill="{y:GraphMLReference 24}"/>
</yjs:PolylineEdgeStyle.targetArrow>
</yjs:PolylineEdgeStyle>
</data>
</edge>
<edge id="e1" source="n21::n1" target="n1" sourceport="p3" targetport="p0">
<data key="d11">4</data>
<data key="d12">
<x:List>
<y:Bend Location="665.6111111111111,364"/>
<y:Bend Location="618.1111111111111,364"/>
</x:List>
</data>
<data key="d13">
<yjs:PolylineEdgeStyle sourceArrow="{y:GraphMLReference 22}">
<yjs:PolylineEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 25}" miterLimit="1.45"/>
</yjs:PolylineEdgeStyle.stroke>
<yjs:PolylineEdgeStyle.targetArrow>
<yjs:Arrow stroke="#FFA72920" fill="{y:GraphMLReference 25}"/>
</yjs:PolylineEdgeStyle.targetArrow>
</yjs:PolylineEdgeStyle>
</data>
</edge>
<edge id="e2" source="n21::n1" target="n2" sourceport="p4" targetport="p0">
<data key="d11">2</data>
<data key="d12">
<x:List>
<y:Bend Location="655.6111111111111,349"/>
<y:Bend Location="480.4087301587302,349"/>
</x:List>
</data>
<data key="d13">
<yjs:PolylineEdgeStyle sourceArrow="{y:GraphMLReference 22}">
<yjs:PolylineEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 26}" miterLimit="1.45"/>
</yjs:PolylineEdgeStyle.stroke>
<yjs:PolylineEdgeStyle.targetArrow>
<yjs:Arrow stroke="#FFF6B600" fill="{y:GraphMLReference 26}"/>
</yjs:PolylineEdgeStyle.targetArrow>
</yjs:PolylineEdgeStyle>
</data>
</edge>
<edge id="e3" source="n21::n1" target="n3" sourceport="p5" targetport="p0">
<data key="d11">3</data>
<data key="d12">
<x:List>
<y:Bend Location="645.6111111111111,334"/>
<y:Bend Location="345.36944444444447,334"/>
</x:List>
</data>
<data key="d13">
<yjs:PolylineEdgeStyle sourceArrow="{y:GraphMLReference 22}">
<yjs:PolylineEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 27}" miterLimit="1.45"/>
</yjs:PolylineEdgeStyle.stroke>
<yjs:PolylineEdgeStyle.targetArrow>
<yjs:Arrow stroke="#FF61993B" fill="{y:GraphMLReference 27}"/>
</yjs:PolylineEdgeStyle.targetArrow>
</yjs:PolylineEdgeStyle>
</data>
</edge>
<edge id="e4" source="n21::n1" target="n22::n1" sourceport="p6" targetport="p1">
<data key="d11">1</data>
<data key="d12">
<x:List>
<y:Bend Location="705.6111111111111,319"/>
<y:Bend Location="1647.3194444444443,319"/>
</x:List>
</data>
<data key="d13">
<yjs:PolylineEdgeStyle sourceArrow="{y:GraphMLReference 22}">
<yjs:PolylineEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 28}" miterLimit="1.45"/>
</yjs:PolylineEdgeStyle.stroke>
<yjs:PolylineEdgeStyle.targetArrow>
<yjs:Arrow stroke="#FF7E4B26" fill="{y:GraphMLReference 28}"/>
</yjs:PolylineEdgeStyle.targetArrow>
</yjs:PolylineEdgeStyle>
</data>
</edge>
<edge id="e5" source="n22::n1" target="n4" sourceport="p2" targetport="p0">
<data key="d11">2</data>
<data key="d12">
<x:List>
<y:Bend Location="1686.9027777777778,608.5"/>
<y:Bend Location="1755.7035714285714,608.5"/>
<y:Bend Location="1755.7035714285714,1137"/>
<y:Bend Location="1662.703373015873,1137"/>
</x:List>
</data>
<data key="d13">
<yjs:PolylineEdgeStyle sourceArrow="{y:GraphMLReference 22}">
<yjs:PolylineEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 29}" miterLimit="1.45"/>
</yjs:PolylineEdgeStyle.stroke>
<yjs:PolylineEdgeStyle.targetArrow>
<yjs:Arrow stroke="#FFF67828" fill="{y:GraphMLReference 29}"/>
</yjs:PolylineEdgeStyle.targetArrow>
</yjs:PolylineEdgeStyle>
</data>
</edge>
<edge id="e6" source="n22::n1" target="n5" sourceport="p3" targetport="p0">
<data key="d11">7</data>
<data key="d12">
<x:List>
<y:Bend Location="1699.4027777777778,593.5"/>
<y:Bend Location="1835.7035714285714,593.5"/>
</x:List>
</data>
<data key="d13">
<yjs:PolylineEdgeStyle sourceArrow="{y:GraphMLReference 22}">
<yjs:PolylineEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 30}" miterLimit="1.45"/>
</yjs:PolylineEdgeStyle.stroke>
<yjs:PolylineEdgeStyle.targetArrow>
<yjs:Arrow stroke="#FF0E0E10" fill="{y:GraphMLReference 30}"/>
</yjs:PolylineEdgeStyle.targetArrow>
</yjs:PolylineEdgeStyle>
</data>
</edge>
<edge id="e7" source="n22::n1" target="n6" sourceport="p4" targetport="p0">
<data key="d11">6</data>
<data key="d12">
<x:List>
<y:Bend Location="1724.4027777777778,563.5"/>
<y:Bend Location="2104.0650793650793,563.5"/>
</x:List>
</data>
<data key="d13">
<yjs:PolylineEdgeStyle sourceArrow="{y:GraphMLReference 22}">
<yjs:PolylineEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 31}" miterLimit="1.45"/>
</yjs:PolylineEdgeStyle.stroke>
<yjs:PolylineEdgeStyle.targetArrow>
<yjs:Arrow stroke="#FFF6B600" fill="{y:GraphMLReference 31}"/>
</yjs:PolylineEdgeStyle.targetArrow>
</yjs:PolylineEdgeStyle>
</data>
</edge>
<edge id="e8" source="n22::n1" target="n21::n1" sourceport="p7" targetport="p7">
<data key="d11">8</data>
<data key="d12">
<x:List>
<y:Bend Location="1680.6527777777778,418"/>
<y:Bend Location="1674.986111111111,418"/>
<y:Bend Location="1674.986111111111,304"/>
<y:Bend Location="715.6111111111111,304"/>
</x:List>
</data>
<data key="d13">
<yjs:PolylineEdgeStyle sourceArrow="{y:GraphMLReference 22}">
<yjs:PolylineEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 32}" miterLimit="1.45"/>
</yjs:PolylineEdgeStyle.stroke>
<yjs:PolylineEdgeStyle.targetArrow>
<yjs:Arrow stroke="#FFA72920" fill="{y:GraphMLReference 32}"/>
</yjs:PolylineEdgeStyle.targetArrow>
</yjs:PolylineEdgeStyle>
</data>
</edge>
<edge id="e9" source="n11" target="n12" sourceport="p1" targetport="p1">
<data key="d13">
<yjs:ArcEdgeStyle height="-0.5073650479316711" sourceArrow="{y:GraphMLReference 22}">
<yjs:ArcEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 33}" miterLimit="1.45"/>
</yjs:ArcEdgeStyle.stroke>
<yjs:ArcEdgeStyle.targetArrow>
<yjs:Arrow fill="{y:GraphMLReference 33}">
<yjs:Arrow.stroke>
<yjs:Stroke fill="{y:GraphMLReference 33}" miterLimit="1.45"/>
</yjs:Arrow.stroke>
</yjs:Arrow>
</yjs:ArcEdgeStyle.targetArrow>
</yjs:ArcEdgeStyle>
</data>
</edge>
<edge id="e10" source="n9" target="n8" sourceport="p1" targetport="p1">
<data key="d13">
<yjs:ArcEdgeStyle height="0.4530050754547119" sourceArrow="{y:GraphMLReference 22}">
<yjs:ArcEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 34}" miterLimit="1.45"/>
</yjs:ArcEdgeStyle.stroke>
<yjs:ArcEdgeStyle.targetArrow>
<yjs:Arrow fill="{y:GraphMLReference 34}">
<yjs:Arrow.stroke>
<yjs:Stroke fill="{y:GraphMLReference 34}" miterLimit="1.45"/>
</yjs:Arrow.stroke>
</yjs:Arrow>
</yjs:ArcEdgeStyle.targetArrow>
</yjs:ArcEdgeStyle>
</data>
</edge>
<edge id="e11" source="n23::n1" target="n13" sourceport="p2" targetport="p0">
<data key="d11">0</data>
<data key="d12">
<x:List>
<y:Bend Location="1234.5003968253968,849"/>
<y:Bend Location="1089.722619047619,849"/>
</x:List>
</data>
<data key="d13">
<yjs:PolylineEdgeStyle sourceArrow="{y:GraphMLReference 22}">
<yjs:PolylineEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 35}" miterLimit="1.45"/>
</yjs:PolylineEdgeStyle.stroke>
<yjs:PolylineEdgeStyle.targetArrow>
<yjs:Arrow stroke="#FF0E0E10" fill="{y:GraphMLReference 35}"/>
</yjs:PolylineEdgeStyle.targetArrow>
</yjs:PolylineEdgeStyle>
</data>
</edge>
<edge id="e12" source="n23::n1" target="n14" sourceport="p3" targetport="p0">
<data key="d11">4</data>
<data key="d12">
<x:List>
<y:Bend Location="1223.3892857142857,834"/>
<y:Bend Location="959.722619047619,834"/>
</x:List>
</data>
<data key="d13">
<yjs:PolylineEdgeStyle sourceArrow="{y:GraphMLReference 22}">
<yjs:PolylineEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 36}" miterLimit="1.45"/>
</yjs:PolylineEdgeStyle.stroke>
<yjs:PolylineEdgeStyle.targetArrow>
<yjs:Arrow stroke="#FFF6B600" fill="{y:GraphMLReference 36}"/>
</yjs:PolylineEdgeStyle.targetArrow>
</yjs:PolylineEdgeStyle>
</data>
</edge>
<edge id="e13" source="n23::n1" target="n15" sourceport="p4" targetport="p0">
<data key="d11">3</data>
<data key="d12">
<x:List>
<y:Bend Location="1212.2781746031746,819"/>
<y:Bend Location="876.222619047619,819"/>
<y:Bend Location="876.222619047619,1137"/>
<y:Bend Location="892.972619047619,1137"/>
</x:List>
</data>
<data key="d13">
<yjs:PolylineEdgeStyle sourceArrow="{y:GraphMLReference 22}">
<yjs:PolylineEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 37}" miterLimit="1.45"/>
</yjs:PolylineEdgeStyle.stroke>
<yjs:PolylineEdgeStyle.targetArrow>
<yjs:Arrow stroke="#FF61993B" fill="{y:GraphMLReference 37}"/>
</yjs:PolylineEdgeStyle.targetArrow>
</yjs:PolylineEdgeStyle>
</data>
</edge>
<edge id="e14" source="n14" target="n15" sourceport="p1" targetport="p1">
<data key="d13">
<yjs:ArcEdgeStyle height="-0.5073650479316711" sourceArrow="{y:GraphMLReference 22}">
<yjs:ArcEdgeStyle.stroke>
<yjs:Stroke fill="{y:GraphMLReference 38}" miterLimit="1.45"/>
</yjs:ArcEdgeStyle.stroke>
<yjs:ArcEdgeStyle.targetArrow>
<yjs:Arrow fill="{y:GraphMLReference 38}">
<yjs:Arrow.stroke>
<yjs:Stroke fill="{y:GraphMLReference 38}" miterLimit="1.45"/>
</yjs:Arrow.stroke>
</yjs:Arrow>
</yjs:ArcEdgeStyle.targetArrow>
</yjs:ArcEdgeStyle>
</data>
</edge>
<edge id="e15" source="n21::n1" target="n16" sourceport="p8" targetport="p0">
<data key="d11">6</data>
<data key="d12">
<x:List>
<y:Bend Location="635.6111111111111,319"/>
<y:Bend Location="215.36944444444444,319"/>
</x:List>
</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
<edge id="e16" source="n21::n1" target="n7" sourceport="p9" targetport="p1">
<data key="d11">5</data>
<data key="d12">
<x:List>
<y:Bend Location="675.6111111111111,364"/>
<y:Bend Location="723.1111111111111,364"/>
</x:List>
</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
<edge id="e17" source="n7" target="n21::n1" sourceport="p2" targetport="p10">
<data key="d12">
<x:List>
<y:Bend Location="773.1111111111111,349"/>
<y:Bend Location="685.6111111111111,349"/>
</x:List>
</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
<edge id="e18" source="n22::n1" target="n10" sourceport="p11" targetport="p1">
<data key="d11">5</data>
<data key="d12">
<x:List>
<y:Bend Location="1711.9027777777778,578.5"/>
<y:Bend Location="1965.7035714285714,578.5"/>
</x:List>
</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
<edge id="e19" source="n22::n1" target="n11" sourceport="p12" targetport="p2">
<data key="d11">4</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
<edge id="e20" source="n22::n1" target="n12" sourceport="p13" targetport="p2">
<data key="d11">3</data>
<data key="d12">
<x:List>
<y:Bend Location="1661.9027777777778,611.5"/>
<y:Bend Location="1586.2031746031746,611.5"/>
<y:Bend Location="1586.2031746031746,906"/>
<y:Bend Location="1639.404365079365,906"/>
</x:List>
</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
<edge id="e21" source="n23::n1" target="n9" sourceport="p11" targetport="p2">
<data key="d11">2</data>
<data key="d12">
<x:List>
<y:Bend Location="1267.83373015873,834"/>
<y:Bend Location="1389.7031746031746,834"/>
</x:List>
</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
<edge id="e22" source="n22::n1" target="n23::n1" sourceport="p14" targetport="p12">
<data key="d11">1</data>
<data key="d12">
<x:List>
<y:Bend Location="1636.9027777777778,581.5"/>
<y:Bend Location="1251.2781746031746,581.5"/>
<y:Bend Location="1251.2781746031746,665.5"/>
<y:Bend Location="1245.611507936508,665.5"/>
</x:List>
</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
<edge id="e23" source="n23::n1" target="n22::n1" sourceport="p13" targetport="p15">
<data key="d11">8</data>
<data key="d12">
<x:List>
<y:Bend Location="1278.9448412698412,596.5"/>
<y:Bend Location="1649.4027777777778,596.5"/>
</x:List>
</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
<edge id="e24" source="n21::n1" target="n17" sourceport="p11" targetport="p0">
<data key="d11">8</data>
<data key="d12">
<x:List>
<y:Bend Location="695.6111111111111,334"/>
<y:Bend Location="828.1111111111111,334"/>
<y:Bend Location="828.1111111111111,918"/>
<y:Bend Location="767.722619047619,918"/>
</x:List>
</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
<edge id="e25" source="n23::n1" target="n17" sourceport="p14" targetport="p1">
<data key="d11">5</data>
<data key="d12">
<x:List>
<y:Bend Location="1201.1670634920633,801"/>
<y:Bend Location="861.6111111111111,801"/>
<y:Bend Location="861.6111111111111,933"/>
<y:Bend Location="817.722619047619,933"/>
</x:List>
</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
<edge id="e26" source="n23::n1" target="n18" sourceport="p15" targetport="p2">
<data key="d11">7</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
<edge id="e27" source="n18" target="n19" sourceport="p3" targetport="p2">
<data key="d12">
<x:List>
<y:Bend Location="1256.722619047619,1137"/>
<y:Bend Location="1223.222619047619,1137"/>
</x:List>
</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
<edge id="e28" source="n23::n1" target="n19" sourceport="p16" targetport="p3">
<data key="d11">6</data>
<data key="d12">
<x:List>
<y:Bend Location="1245.611507936508,864"/>
<y:Bend Location="1173.222619047619,864"/>
</x:List>
</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
<edge id="e29" source="n23::n1" target="n20" sourceport="p17" targetport="p0">
<data key="d11">1</data>
<data key="d12">
<x:List>
<y:Bend Location="1278.9448412698412,819"/>
<y:Bend Location="1494.7031746031746,819"/>
</x:List>
</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
<edge id="e30" source="n20" target="n23::n1" sourceport="p1" targetport="p18">
<data key="d11">8</data>
<data key="d12">
<x:List>
<y:Bend Location="1544.7031746031746,801"/>
<y:Bend Location="1290.0559523809522,801"/>
</x:List>
</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
<edge id="e31" source="n20" target="n4" sourceport="p2" targetport="p2">
<data key="d11">2</data>
<data key="d12">
<x:List>
<y:Bend Location="1519.7031746031746,1149"/>
<y:Bend Location="1612.703373015873,1149"/>
</x:List>
</data>
<data key="d13">
<y:GraphMLReference ResourceKey="42"/>
</data>
</edge>
</graph>
</graphml>

View File

@@ -0,0 +1,966 @@
{
"nodes": [
{
"id": "YugongStation",
"name": "愚公常量合成工作站",
"children": [
"serial_pump",
"pump_reagents",
"flask_CH2Cl2",
"flask_acetone",
"flask_NH4Cl",
"flask_grignard",
"flask_THF",
"reactor",
"pump_workup",
"waste_workup",
"separator_controller",
"flask_separator",
"flask_holding",
"flask_H2O",
"flask_NaHCO3",
"pump_column",
"rotavap",
"flask_rv",
"column",
"flask_column",
"flask_air",
"dry_column",
"flask_dry_column",
"pump_ext",
"stirrer"
],
"parent": null,
"type": "device",
"class": "workstation",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"protocol_type": ["PumpTransferProtocol", "CleanProtocol", "SeparateProtocol", "EvaporateProtocol"]
},
"data": {
}
},
{
"id": "serial_pump",
"name": "serial_pump",
"children": [],
"parent": "YugongStation",
"type": "device",
"class": "serial",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"port": "COM7",
"baudrate": 9600
},
"data": {
}
},
{
"id": "pump_reagents",
"name": "pump_reagents",
"children": [],
"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"
}
},
{
"id": "flask_CH2Cl2",
"name": "flask_CH2Cl2",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 430.4087301587302,
"y": 428,
"z": 0
},
"config": {
"max_volume": 2000.0
},
"data": {
"liquid": [
{
"liquid_type": "CH2Cl2",
"liquid_volume": 1500.0
}
]
}
},
{
"id": "flask_acetone",
"name": "flask_acetone",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 295.36944444444447,
"y": 428,
"z": 0
},
"config": {
"max_volume": 2000.0
},
"data": {
"liquid": [
{
"liquid_type": "acetone",
"liquid_volume": 1500.0
}
]
}
},
{
"id": "flask_NH4Cl",
"name": "flask_NH4Cl",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 165.36944444444444,
"y": 428,
"z": 0
},
"config": {
"max_volume": 2000.0
},
"data": {
"liquid": [
{
"liquid_type": "NH4Cl",
"liquid_volume": 1500.0
}
]
}
},
{
"id": "flask_grignard",
"name": "flask_grignard",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 165.36944444444444,
"y": 428,
"z": 0
},
"config": {
"max_volume": 2000.0
},
"data": {
"liquid": [
{
"liquid_type": "grignard",
"liquid_volume": 1500.0
}
]
}
},
{
"id": "flask_THF",
"name": "flask_THF",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 35,
"y": 428,
"z": 0
},
"config": {
"max_volume": 2000.0
},
"data": {
"liquid": [
{
"liquid_type": "THF",
"liquid_volume": 1500.0
}
]
}
},
{
"id": "reactor",
"name": "reactor",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 698.1111111111111,
"y": 428,
"z": 0
},
"config": {
"max_volume": 5000.0
},
"data": {
"liquid": [
]
}
},
{
"id": "stirrer",
"name": "stirrer",
"children": [],
"parent": "YugongStation",
"type": "device",
"class": "heaterstirrer.dalong",
"position": {
"x": 698.1111111111111,
"y": 478,
"z": 0
},
"config": {
"port": "COM43",
"temp_warning": 60.0
},
"data": {
"status": "Idle",
"temp": 0.0,
"stir_speed": 0.0
}
},
{
"id": "pump_workup",
"name": "pump_workup",
"children": [],
"parent": "YugongStation",
"type": "device",
"class": "syringepump.runze",
"position": {
"x": 1195.611507936508,
"y": 686,
"z": 0
},
"config": {
"port": "/devices/PumpBackbone/Serial/serialwrite",
"address": "2",
"max_volume": 25.0
},
"data": {
"max_velocity": 1.0,
"position": 0.0,
"status": "Idle",
"valve_position": "0"
}
},
{
"id": "waste_workup",
"name": "waste_workup",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 1587.703373015873,
"y": 1172.5,
"z": 0
},
"config": {
"max_volume": 2000.0
},
"data": {
"liquid": [
]
}
},
{
"id": "separator_controller",
"name": "separator_controller",
"children": [],
"parent": "YugongStation",
"type": "device",
"class": "separator_controller",
"position": {
"x": 1624.4027777777778,
"y": 665.5,
"z": 0
},
"config": {
"port_executor": "COM41",
"port_sensor": "COM40"
},
"data": {
"sensordata": 0.0,
"status": "Idle"
}
},
{
"id": "flask_separator",
"name": "flask_separator",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 1614.404365079365,
"y": 948,
"z": 0
},
"config": {
"max_volume": 2000.0
},
"data": {
"liquid": [
]
}
},
{
"id": "flask_holding",
"name": "flask_holding",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 1915.7035714285714,
"y": 665.5,
"z": 0
},
"config": {
"max_volume": 2000.0
},
"data": {
"liquid": [
]
}
},
{
"id": "flask_H2O",
"name": "flask_H2O",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 1785.7035714285714,
"y": 665.5,
"z": 0
},
"config": {
"max_volume": 2000.0
},
"data": {
"liquid": [
{
"liquid_type": "H2O",
"liquid_volume": 1500.0
}
]
}
},
{
"id": "flask_NaHCO3",
"name": "flask_NaHCO3",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 2054.0650793650793,
"y": 665.5,
"z": 0
},
"config": {
"max_volume": 2000.0
},
"data": {
"liquid": [
{
"liquid_type": "NaHCO3",
"liquid_volume": 1500.0
}
]
}
},
{
"id": "pump_column",
"name": "pump_column",
"children": [],
"parent": "YugongStation",
"type": "device",
"class": "syringepump.runze",
"position": {
"x": 1630.6527777777778,
"y": 448.5,
"z": 0
},
"config": {
"port": "/devices/PumpBackbone/Serial/serialwrite",
"address": "3",
"max_volume": 25.0
},
"data": {
"max_velocity": 1.0,
"position": 0.0,
"status": "Idle",
"valve_position": "0"
}
},
{
"id": "rotavap",
"name": "rotavap",
"children": [],
"parent": "YugongStation",
"type": "device",
"class": "rotavap",
"position": {
"x": 1339.7031746031746,
"y": 968.5,
"z": 0
},
"config": {
"port": "COM15"
},
"data": {
"temperature": 0.0,
"rotate_time": 0.0,
"status": "Idle"
}
},
{
"id": "flask_rv",
"name": "flask_rv",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 1339.7031746031746,
"y": 1152,
"z": 0
},
"config": {
"max_volume": 2000.0
},
"data": {
"liquid": [
]
}
},
{
"id": "column",
"name": "column",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 909.722619047619,
"y": 948,
"z": 0
},
"config": {
"max_volume": 200.0
},
"data": {
"liquid": [
]
}
},
{
"id": "flask_column",
"name": "flask_column",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 867.972619047619,
"y": 1152,
"z": 0
},
"config": {
"max_volume": 2000.0
},
"data": {
"liquid": [
]
}
},
{
"id": "flask_air",
"name": "flask_air",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 742.722619047619,
"y": 948,
"z": 0
},
"config": {
"max_volume": 2000.0
},
"data": {
"liquid": [
]
}
},
{
"id": "dry_column",
"name": "dry_column",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 1206.722619047619,
"y": 948,
"z": 0
},
"config": {
"max_volume": 200.0
},
"data": {
"liquid": [
]
}
},
{
"id": "flask_dry_column",
"name": "flask_dry_column",
"children": [],
"parent": "YugongStation",
"type": "container",
"class": null,
"position": {
"x": 1148.222619047619,
"y": 1152,
"z": 0
},
"config": {
"max_volume": 2000.0
},
"data": {
"liquid": [
]
}
},
{
"id": "pump_ext",
"name": "pump_ext",
"children": [],
"parent": "YugongStation",
"type": "device",
"class": "syringepump.runze",
"position": {
"x": 1469.7031746031746,
"y": 968.5,
"z": 0
},
"config": {
"port": "/devices/PumpBackbone/Serial/serialwrite",
"address": "4",
"max_volume": 25.0
},
"data": {
"max_velocity": 1.0,
"position": 0.0,
"status": "Idle",
"valve_position": "0"
}
},
{
"id": "AGV",
"name": "AGV",
"children": ["zhixing_agv", "zhixing_ur_arm"],
"parent": null,
"type": "device",
"class": "workstation",
"position": {
"x": 698.1111111111111,
"y": 478,
"z": 0
},
"config": {
"protocol_type": ["AGVTransferProtocol"]
},
"data": {
}
},
{
"id": "zhixing_agv",
"name": "zhixing_agv",
"children": [],
"parent": "AGV",
"type": "device",
"class": "zhixing_agv",
"position": {
"x": 698.1111111111111,
"y": 478,
"z": 0
},
"config": {
"host": "192.168.1.42"
},
"data": {
}
},
{
"id": "zhixing_ur_arm",
"name": "zhixing_ur_arm",
"children": [],
"parent": "AGV",
"type": "device",
"class": "zhixing_ur_arm",
"position": {
"x": 698.1111111111111,
"y": 478,
"z": 0
},
"config": {
"host": "192.168.1.178"
},
"data": {
}
}
],
"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_reagents": "port",
"serial_pump": "port"
}
},
{
"source": "pump_column",
"target": "serial_pump",
"type": "communication",
"port": {
"pump_reagents": "port",
"serial_pump": "port"
}
},
{
"source": "pump_ext",
"target": "serial_pump",
"type": "communication",
"port": {
"pump_reagents": "port",
"serial_pump": "port"
}
},
{
"source": "reactor",
"target": "pump_reagents",
"type": "physical",
"port": {
"reactor": "top",
"pump_reagents": "5"
}
},
{
"source": "rotavap",
"target": "flask_rv",
"type": "physical",
"port": {
"rotavap": "bottom",
"flask_rv": "top"
}
},
{
"source": "separator_controller",
"target": "flask_separator",
"type": "physical",
"port": {
"separator_controller": "bottom",
"flask_separator": "top"
}
},
{
"source": "column",
"target": "flask_column",
"type": "physical",
"port": {
"column": "bottom",
"flask_column": "top"
}
},
{
"source": "dry_column",
"target": "flask_dry_column",
"type": "physical",
"port": {
"dry_column": "bottom",
"flask_dry_column": "top"
}
},
{
"source": "pump_ext",
"target": "pump_column",
"type": "physical",
"port": {
"pump_ext": "8",
"pump_column": "1"
}
},
{
"source": "pump_ext",
"target": "waste_workup",
"type": "physical",
"port": {
"pump_ext": "2",
"waste_workup": "-1"
}
},
{
"source": "pump_reagents",
"target": "flask_THF",
"type": "physical",
"port": {
"pump_reagents": "7",
"flask_THF": "top"
}
},
{
"source": "pump_reagents",
"target": "flask_NH4Cl",
"type": "physical",
"port": {
"pump_reagents": "4",
"flask_NH4Cl": "top"
}
},
{
"source": "pump_reagents",
"target": "flask_CH2Cl2",
"type": "physical",
"port": {
"pump_reagents": "2",
"flask_CH2Cl2": "top"
}
},
{
"source": "pump_reagents",
"target": "flask_acetone",
"type": "physical",
"port": {
"pump_reagents": "3",
"flask_acetone": "top"
}
},
{
"source": "pump_reagents",
"target": "pump_workup",
"type": "physical",
"port": {
"pump_reagents": "1",
"pump_workup": "8"
}
},
{
"source": "pump_reagents",
"target": "flask_grignard",
"type": "physical",
"port": {
"pump_reagents": "6",
"flask_grignard": "top"
}
},
{
"source": "pump_reagents",
"target": "reactor",
"type": "physical",
"port": {
"pump_reagents": "5",
"reactor": "top"
}
},
{
"source": "pump_reagents",
"target": "flask_air",
"type": "physical",
"port": {
"pump_reagents": "8",
"flask_air": "-1"
}
},
{
"source": "pump_workup",
"target": "waste_workup",
"type": "physical",
"port": {
"pump_workup": "2",
"waste_workup": "-1"
}
},
{
"source": "pump_workup",
"target": "flask_H2O",
"type": "physical",
"port": {
"pump_workup": "7",
"flask_H2O": "top"
}
},
{
"source": "pump_workup",
"target": "flask_NaHCO3",
"type": "physical",
"port": {
"pump_workup": "6",
"flask_NaHCO3": "top"
}
},
{
"source": "pump_workup",
"target": "pump_reagents",
"type": "physical",
"port": {
"pump_workup": "8",
"pump_reagents": "1"
}
},
{
"source": "pump_workup",
"target": "flask_holding",
"type": "physical",
"port": {
"pump_workup": "5",
"flask_holding": "top"
}
},
{
"source": "pump_workup",
"target": "separator_controller",
"type": "physical",
"port": {
"pump_workup": "4",
"separator_controller": "top"
}
},
{
"source": "pump_workup",
"target": "flask_separator",
"type": "physical",
"port": {
"pump_workup": "3",
"flask_separator": "top"
}
},
{
"source": "pump_workup",
"target": "pump_column",
"type": "physical",
"port": {
"pump_workup": "1",
"pump_column": "8"
}
},
{
"source": "pump_column",
"target": "column",
"type": "physical",
"port": {
"pump_column": "4",
"column": "top"
}
},
{
"source": "pump_column",
"target": "flask_column",
"type": "physical",
"port": {
"pump_column": "3",
"flask_column": "top"
}
},
{
"source": "pump_column",
"target": "rotavap",
"type": "physical",
"port": {
"pump_column": "2",
"rotavap": "-1"
}
},
{
"source": "pump_column",
"target": "pump_workup",
"type": "physical",
"port": {
"pump_column": "8",
"pump_workup": "1"
}
},
{
"source": "pump_column",
"target": "flask_air",
"type": "physical",
"port": {
"pump_column": "5",
"flask_air": "-1"
}
},
{
"source": "pump_column",
"target": "dry_column",
"type": "physical",
"port": {
"pump_column": "7",
"dry_column": "top"
}
},
{
"source": "pump_column",
"target": "flask_dry_column",
"type": "physical",
"port": {
"pump_column": "6",
"flask_dry_column": "top"
}
},
{
"source": "pump_column",
"target": "pump_ext",
"type": "physical",
"port": {
"pump_column": "1",
"pump_ext": "8"
}
}
]
}

174
test/experiments/HPLC.json Normal file
View File

@@ -0,0 +1,174 @@
{
"nodes": [
{
"id": "HPLC",
"name": "HPLC",
"parent": null,
"type": "device",
"class": "hplc",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {},
"data": {},
"children": [
"BottlesRack3"
]
},
{
"id": "BottlesRack3",
"name": "Revvity上样盘3",
"parent": "Revvity",
"type": "plate",
"class": null,
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {},
"data": {},
"children": [
"Bottle3-1",
"Bottle3-2",
"Bottle3-3",
"Bottle3-4",
"Bottle3-5",
"Bottle3-6",
"Bottle3-7",
"Bottle3-8"
]
},
{
"id": "Bottle3-1",
"name": "Bottle3-1",
"parent": "BottlesRack3",
"type": "container",
"class": null,
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {},
"data": {},
"children": [
]
},
{
"id": "Bottle3-2",
"name": "Bottle3-2",
"parent": "BottlesRack3",
"type": "container",
"class": null,
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {},
"data": {},
"children": [
]
},
{
"id": "Bottle3-3",
"name": "Bottle3-3",
"parent": "BottlesRack3",
"type": "container",
"class": null,
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {},
"data": {},
"children": [
]
},
{
"id": "Bottle3-4",
"name": "Bottle3-4",
"parent": "BottlesRack3",
"type": "container",
"class": null,
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {},
"data": {},
"children": [
]
},
{
"id": "Bottle3-5",
"name": "Bottle3-5",
"parent": "BottlesRack3",
"type": "container",
"class": null,
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {},
"data": {},
"children": [
]
},
{
"id": "Bottle3-6",
"name": "Bottle3-6",
"parent": "BottlesRack3",
"type": "container",
"class": null,
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {},
"data": {},
"children": [
]
},
{
"id": "Bottle3-7",
"name": "Bottle3-7",
"parent": "BottlesRack3",
"type": "container",
"class": null,
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {},
"data": {},
"children": [
]
},
{
"id": "Bottle3-8",
"name": "Bottle3-8",
"parent": "BottlesRack3",
"type": "container",
"class": null,
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {},
"data": {},
"children": [
]
}
],
"links": []
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
controller_manager:
ros__parameters:
update_rate: 1.0 # 更新频率,单位 Hz. 20s 更新一次
# 控制器列表
controllers:
deis_current_controller:
type: EISModelBasedController # PID 控制器类型
inputs:
eis: # 环境输入(电化学阻抗谱)
topic: /devices/BioLogic/EISdata # 输入话题
type: list[float] # 输入数据类型
outputs:
current_control: # 控制输出(充电电流)
topic: /devices/BioLogic/current_control # 输出话题
type: float # 输出数据类型
parameters:
# set_point: 22.0 # 目标温度(可以动态更新)
# kp: 1.0 # PID 参数
# ki: 0.1
# kd: 0.05

View File

@@ -0,0 +1,54 @@
{
"PumpBackbone": {
"class": "protocol",
"protocol_type": ["PumpTransferProtocol", "CleanProtocol", "SeparateProtocol", "EvaporateProtocol"],
"children": {
"Serial": {
"class": "serial",
"port": "COM7",
"baudrate": 9600
},
"pump_reagents": {
"class": "syringepump.runze",
"port": "/devices/PumpBackbone/Serial/serialwrite",
"address": "1"
},
"pump_workup": {
"class": "syringepump.runze",
"port": "/devices/PumpBackbone/Serial/serialwrite",
"address": "2"
},
"pump_column": {
"class": "syringepump.runze",
"port": "/devices/PumpBackbone/Serial/serialwrite",
"address": "3"
},
"pump_ext": {
"class": "syringepump.runze",
"port": "/devices/PumpBackbone/Serial/serialwrite",
"address": "4"
},
"separator_controller": {
"class": "separator_controller",
"port_executor": "COM41",
"port_sensor": "COM40"
},
"rotavap_chiller": {
"class": "chiller",
"port": "COM17"
},
"rotavap_controller": {
"class": "rotavap",
"port": "COM15"
}
}
},
"AichemecoHiWo": {
"class": "work_station.aichemeco_hiwo"
},
"Stirrer": {
"class": "heaterstirrer.dalong",
"port": "COM43"
}
}

View File

@@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,158 @@
{
"nodes": [
{
"id": "ReactorX",
"name": "模拟常量合成工作站",
"children": [
"reactor",
"vacuum_valve",
"gas_valve",
"vacuum_pump",
"gas_source"
],
"parent": null,
"type": "device",
"class": "workstation",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"protocol_type": ["EvacuateAndRefillProtocol"]
},
"data": {
}
},
{
"id": "reactor",
"name": "reactor",
"children": [],
"parent": "ReactorX",
"type": "container",
"class": null,
"position": {
"x": 698.1111111111111,
"y": 428,
"z": 0
},
"config": {
"max_volume": 5000.0
},
"data": {
"liquid": [
]
}
},
{
"id": "vacuum_valve",
"name": "vacuum_valve",
"children": [
],
"parent": "ReactorX",
"type": "device",
"class": "solenoid_valve.mock",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
},
"data": {
}
},
{
"id": "gas_valve",
"name": "gas_valve",
"children": [
],
"parent": "ReactorX",
"type": "device",
"class": "solenoid_valve.mock",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
},
"data": {
}
},
{
"id": "vacuum_pump",
"name": "vacuum_pump",
"children": [
],
"parent": "ReactorX",
"type": "device",
"class": "vacuum_pump.mock",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
},
"data": {
}
},
{
"id": "gas_source",
"name": "gas_source",
"children": [
],
"parent": "ReactorX",
"type": "device",
"class": "gas_source.mock",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
},
"data": {
}
}
],
"links": [
{
"source": "reactor",
"target": "vacuum_valve",
"type": "physical",
"port": {
"reactor": "top",
"vacuum_valve": "1"
}
},
{
"source": "reactor",
"target": "gas_valve",
"type": "physical",
"port": {
"reactor": "top",
"gas_valve": "1"
}
},
{
"source": "vacuum_pump",
"target": "vacuum_valve",
"type": "physical",
"port": {
"vacuum_pump": "out",
"vacuum_valve": "0"
}
},
{
"source": "gas_source",
"target": "gas_valve",
"type": "physical",
"port": {
"gas_source": "out",
"gas_valve": "0"
}
}
]
}

10553
test/experiments/plr_test.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
{
"nodes": [
{
"id": "Gripper1",
"name": "假夹爪",
"children": [
],
"parent": null,
"type": "device",
"class": "gripper.mock",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
},
"data": {
}
},
{
"id": "Plate1",
"name": "Plate1",
"children": [
],
"parent": null,
"type": "plate",
"class": "nest_96_wellplate_2ml_deep",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
},
"data": {
}
}
],
"links": [
]
}

View File

@@ -0,0 +1 @@
# 消息转换器测试包

View File

@@ -0,0 +1,71 @@
"""
基本测试
测试消息转换器的基本功能,包括导入、类型映射等。
"""
import unittest
from unilabos.ros.msgs.message_converter import (
msg_converter_manager,
get_msg_type,
get_action_type,
get_ros_type_by_msgname,
Point3D,
Point,
Float64,
String,
Bool,
Int32,
)
class TestBasicFunctionality(unittest.TestCase):
"""测试消息转换器的基本功能"""
def test_manager_initialization(self):
"""测试导入管理器初始化"""
self.assertIsNotNone(msg_converter_manager)
self.assertTrue(len(msg_converter_manager.list_modules()) > 0)
self.assertTrue(len(msg_converter_manager.list_classes()) > 0)
def test_get_msg_type(self):
"""测试获取消息类型"""
self.assertEqual(get_msg_type(float), Float64)
self.assertEqual(get_msg_type(str), String)
self.assertEqual(get_msg_type(bool), Bool)
self.assertEqual(get_msg_type(int), Int32)
self.assertEqual(get_msg_type(Point3D), Point)
# 测试错误情况
with self.assertRaises(ValueError):
get_msg_type(set) # 不支持的类型
def test_get_action_type(self):
"""测试获取动作类型"""
float_action = get_action_type(float)
self.assertIsNotNone(float_action)
self.assertTrue("type" in float_action)
self.assertTrue("goal" in float_action)
self.assertTrue("feedback" in float_action)
# 测试错误情况
with self.assertRaises(ValueError):
get_action_type(set) # 不支持的类型
def test_get_ros_type_by_msgname(self):
"""测试通过消息名称获取ROS类型"""
# 测试有效的消息名称
point_type = get_ros_type_by_msgname("geometry_msgs/msg/Point")
self.assertEqual(point_type, Point)
# 测试无效的消息名称
with self.assertRaises(ValueError):
get_ros_type_by_msgname("invalid_format")
# 不存在的消息类型可能会引发ImportError但这依赖于运行环境
# 因此不进行显式测试
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,131 @@
"""
转换测试
测试Python对象和ROS消息之间的转换功能。
"""
import unittest
from dataclasses import dataclass
from unilabos.ros.msgs.message_converter import (
convert_to_ros_msg,
convert_from_ros_msg,
convert_to_ros_msg_with_mapping,
convert_from_ros_msg_with_mapping,
Point,
Float64,
String,
Point3D,
Resource,
)
# 定义一些测试数据类
@dataclass
class TestPoint:
x: float = 0.0
y: float = 0.0
z: float = 0.0
class TestBasicConversion(unittest.TestCase):
"""测试基本类型转换"""
def test_primitive_conversion(self):
"""测试原始类型转换"""
# Float转换
float_value = 3.14
ros_float = convert_to_ros_msg(Float64, float_value)
self.assertEqual(ros_float.data, float_value)
# 反向转换
py_float = convert_from_ros_msg(ros_float)
self.assertEqual(py_float, float_value)
# 字符串转换
str_value = "hello"
ros_str = convert_to_ros_msg(String, str_value)
self.assertEqual(ros_str.data, str_value)
# 反向转换
py_str = convert_from_ros_msg(ros_str)
self.assertEqual(py_str, str_value)
def test_point_conversion(self):
"""测试点类型转换"""
# 创建Point3D对象
py_point = Point3D(x=1.0, y=2.0, z=3.0)
# 转换为ROS Point
ros_point = convert_to_ros_msg(Point, py_point)
self.assertEqual(ros_point.x, py_point.x)
self.assertEqual(ros_point.y, py_point.y)
self.assertEqual(ros_point.z, py_point.z)
# 反向转换
py_point_back = convert_from_ros_msg(ros_point)
self.assertEqual(py_point_back.x, py_point.x)
self.assertEqual(py_point_back.y, py_point.y)
self.assertEqual(py_point_back.z, py_point.z)
def test_dataclass_conversion(self):
"""测试dataclass转换"""
# 创建dataclass
test_point = TestPoint(x=1.0, y=2.0, z=3.0)
# 转换
ros_point = convert_to_ros_msg(Point, test_point)
self.assertEqual(ros_point.x, test_point.x)
self.assertEqual(ros_point.y, test_point.y)
self.assertEqual(ros_point.z, test_point.z)
class TestMappingConversion(unittest.TestCase):
"""测试映射转换功能"""
def test_mapping_conversion(self):
"""测试带映射的转换"""
# 创建测试数据
test_data = {
"position": {"x": 1.0, "y": 2.0, "z": 3.0},
"name": "test_resource",
"id": "123",
"type": "test_type",
}
# 定义映射
mapping = {
"id": "id",
"name": "name",
"type": "type",
"pose.position": "position",
}
# 转换为ROS资源
ros_resource = convert_to_ros_msg_with_mapping(Resource, test_data, mapping)
self.assertEqual(ros_resource.id, "123")
self.assertEqual(ros_resource.name, "test_resource")
self.assertEqual(ros_resource.type, "test_type")
self.assertEqual(ros_resource.pose.position.x, 1.0)
self.assertEqual(ros_resource.pose.position.y, 2.0)
self.assertEqual(ros_resource.pose.position.z, 3.0)
# 反向转换
reverse_mapping = {
"id": "id",
"name": "name",
"type": "type",
"pose.position": "position",
}
py_data = convert_from_ros_msg_with_mapping(ros_resource, reverse_mapping)
self.assertEqual(py_data["id"], "123")
self.assertEqual(py_data["name"], "test_resource")
self.assertEqual(py_data["type"], "test_type")
self.assertEqual(py_data["position"].x, 1.0)
self.assertEqual(py_data["position"].y, 2.0)
self.assertEqual(py_data["position"].z, 3.0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,120 @@
"""
映射测试
测试消息类型映射和字段映射功能。
"""
import unittest
from dataclasses import dataclass
from unilabos.ros.msgs.message_converter import (
_msg_mapping,
_action_mapping,
_msg_converter,
_msg_converter_back,
compare_model_fields,
Point,
Point3D,
Float64,
String,
set_msg_data,
)
@dataclass
class TestMappingModel:
"""用于测试映射的数据类"""
id: str
name: str
value: float
@dataclass
class TestPointModel:
"""用于测试字段比较的点模型"""
x: float
y: float
z: float
class TestTypeMapping(unittest.TestCase):
"""测试类型映射"""
def test_msg_mapping(self):
"""测试消息类型映射"""
self.assertIn(float, _msg_mapping)
self.assertEqual(_msg_mapping[float], Float64)
self.assertIn(str, _msg_mapping)
self.assertEqual(_msg_mapping[str], String)
self.assertIn(Point3D, _msg_mapping)
self.assertEqual(_msg_mapping[Point3D], Point)
def test_action_mapping(self):
"""测试动作类型映射"""
self.assertIn(float, _action_mapping)
self.assertIn("type", _action_mapping[float])
self.assertIn("goal", _action_mapping[float])
self.assertIn("feedback", _action_mapping[float])
self.assertIn("result", _action_mapping[float])
def test_converter_mapping(self):
"""测试转换器映射"""
# 测试Python到ROS映射
self.assertIn(float, _msg_converter)
self.assertIn(Float64, _msg_converter)
self.assertIn(String, _msg_converter)
self.assertIn(Point, _msg_converter)
# 测试ROS到Python映射
self.assertIn(float, _msg_converter_back)
self.assertIn(Float64, _msg_converter_back)
self.assertIn(String, _msg_converter_back)
self.assertIn(Point, _msg_converter_back)
class TestFieldMapping(unittest.TestCase):
"""测试字段映射"""
def test_compare_model_fields(self):
"""测试模型字段比较"""
# Point3D和TestPointModel有相同的字段
self.assertTrue(compare_model_fields(Point3D, TestPointModel))
# 与其他类型比较
self.assertFalse(compare_model_fields(Point3D, TestMappingModel))
self.assertFalse(compare_model_fields(Point3D, float))
# 类型对象和实例对象比较
point = Point3D(x=1.0, y=2.0, z=3.0)
self.assertTrue(compare_model_fields(Point3D, type(point)))
def test_set_msg_data(self):
"""测试设置消息数据类型"""
# 测试float转换
float_value = "3.14"
self.assertEqual(set_msg_data("float", float_value), 3.14)
self.assertEqual(set_msg_data("double", float_value), 3.14)
# 测试int转换
int_value = "42"
self.assertEqual(set_msg_data("int", int_value), 42)
# 测试bool转换
bool_value = "True"
self.assertEqual(set_msg_data("bool", bool_value), True)
# 测试str转换
str_value = "hello"
self.assertEqual(set_msg_data("str", str_value), "hello")
# 测试默认转换
default_value = 123
self.assertEqual(set_msg_data("unknown_type", default_value), "123")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,47 @@
"""
测试运行器
运行所有消息转换器的测试用例。
"""
import unittest
import sys
import os
# 添加项目根目录到路径
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
# 导入测试模块
from test.ros.msgs.test_basic import TestBasicFunctionality
from test.ros.msgs.test_conversion import TestBasicConversion, TestMappingConversion
from test.ros.msgs.test_mapping import TestTypeMapping, TestFieldMapping
def run_tests():
"""运行所有测试"""
# 创建测试加载器
loader = unittest.TestLoader()
# 创建测试套件
suite = unittest.TestSuite()
# 添加测试类
suite.addTests(loader.loadTestsFromTestCase(TestBasicFunctionality))
suite.addTests(loader.loadTestsFromTestCase(TestBasicConversion))
suite.addTests(loader.loadTestsFromTestCase(TestMappingConversion))
suite.addTests(loader.loadTestsFromTestCase(TestTypeMapping))
suite.addTests(loader.loadTestsFromTestCase(TestFieldMapping))
# 创建测试运行器
runner = unittest.TextTestRunner(verbosity=2)
# 运行测试
result = runner.run(suite)
# 返回结果
return result.wasSuccessful()
if __name__ == "__main__":
success = run_tests()
sys.exit(not success)

61
unilabos-linux-64.yaml Normal file
View File

@@ -0,0 +1,61 @@
name: unilab
channels:
- robostack
- robostack-staging
- conda-forge
dependencies:
# Basics
- python=3.11.11
- compilers
- cmake
- make
- ninja
- sphinx
- sphinx_rtd_theme
# Data Visualization
- numpy
- scipy
- pandas
- networkx
- matplotlib
- pint
# Device communication
- pyserial
- pyusb
- pylibftdi
- pymodbus
- python-can
- pyvisa
- opencv
# Service
- pydantic
- fastapi
- uvicorn
- gradio
- flask
- websocket
# Notebook
- ipython
- jupyter
- jupyros
# ros
- colcon-common-extensions
- ros-humble-desktop-full
- ros-humble-control-msgs
- ros-humble-sensor-msgs
- ros-humble-trajectory-msgs
- ros-humble-navigation2
- ros-humble-ros2-control
- ros-humble-robot-state-publisher
- ros-humble-joint-state-publisher
# web
- ros-humble-rosbridge-server
# geometry & motion planning
- ros-humble-tf2
- ros-humble-moveit
- ros-humble-moveit-servo
# simulation
- ros-humble-simulation
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments
# - ros-humble-unilabos-msgs

61
unilabos-osx-64.yaml Normal file
View File

@@ -0,0 +1,61 @@
name: unilab
channels:
- robostack
- robostack-staging
- conda-forge
dependencies:
# Basics
- python=3.11.11
- compilers
- cmake
- make
- ninja
- sphinx
- sphinx_rtd_theme
# Data Visualization
- numpy
- scipy
- pandas
- networkx
- matplotlib
- pint
# Device communication
- pyserial
- pyusb
- pylibftdi
- pymodbus
- python-can
- pyvisa
- opencv
# Service
- pydantic
- fastapi
- uvicorn
- gradio
- flask
- websocket
# Notebook
- ipython
- jupyter
- jupyros
# ros
- colcon-common-extensions
- ros-humble-desktop-full
- ros-humble-control-msgs
- ros-humble-sensor-msgs
- ros-humble-trajectory-msgs
- ros-humble-navigation2
- ros-humble-ros2-control
- ros-humble-robot-state-publisher
- ros-humble-joint-state-publisher
# web
- ros-humble-rosbridge-server
# geometry & motion planning
- ros-humble-tf2
# - ros-humble-moveit // ignored because of NO python3.11 package in OSX
# - ros-humble-moveit-servo
# simulation
- ros-humble-simulation
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments
# - ros-humble-unilabos-msgs

63
unilabos-osx-arm64.yaml Normal file
View File

@@ -0,0 +1,63 @@
name: unilab
channels:
- robostack
- robostack-staging
- conda-forge
dependencies:
# Basics
- python=3.11.11
- compilers
- cmake
- make
- ninja
- sphinx
- sphinx_rtd_theme
# Data Visualization
- numpy
- scipy
- pandas
- networkx
- matplotlib
- pint
- openpyxl
# Device communication
- pyserial
- pyusb
- pylibftdi
- pymodbus
- python-can
- pyvisa
- opencv
# Service
- pydantic
- fastapi
- uvicorn
- gradio
- flask
- websocket
- paho-mqtt
# Notebook
- ipython
- jupyter
- jupyros
# ros
- colcon-common-extensions
- ros-humble-desktop-full
- ros-humble-control-msgs
- ros-humble-sensor-msgs
- ros-humble-trajectory-msgs
- ros-humble-navigation2
- ros-humble-ros2-control
- ros-humble-robot-state-publisher
- ros-humble-joint-state-publisher
# web
- ros-humble-rosbridge-server
# geometry & motion planning
- ros-humble-tf2
- ros-humble-moveit
- ros-humble-moveit-servo
# simulation
- ros-humble-simulation
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments
# - ros-humble-unilabos-msgs

61
unilabos-win64.yaml Normal file
View File

@@ -0,0 +1,61 @@
name: unilab
channels:
- robostack
- robostack-staging
- conda-forge
dependencies:
# Basics
- python=3.11.11
- compilers
- cmake
- make
- ninja
- sphinx
- sphinx_rtd_theme
# Data Visualization
- numpy
- scipy
- pandas
- networkx
- matplotlib
- pint
# Device communication
- pyserial
- pyusb
- pylibftdi
- pymodbus
- python-can
- pyvisa
- opencv
# Service
- pydantic
- fastapi
- uvicorn
- gradio
- flask
- websocket
# Notebook
- ipython
- jupyter
- jupyros
# ros
- colcon-common-extensions
- ros-humble-desktop-full
- ros-humble-control-msgs
- ros-humble-sensor-msgs
- ros-humble-trajectory-msgs
- ros-humble-navigation2
- ros-humble-ros2-control
- ros-humble-robot-state-publisher
- ros-humble-joint-state-publisher
# web
- ros-humble-rosbridge-server
# geometry & motion planning
- ros-humble-tf2
- ros-humble-moveit
- ros-humble-moveit-servo
# simulation
- ros-humble-simulation # ignored because of NO python3.11 package in WIN64
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments
# - ros-humble-unilabos-msgs

0
unilabos/__init__.py Normal file
View File

0
unilabos/app/__init__.py Normal file
View File

35
unilabos/app/backend.py Normal file
View File

@@ -0,0 +1,35 @@
import threading
from unilabos.utils import logger
# 根据选择的 backend 启动相应的功能
def start_backend(
backend: str,
devices_config: dict = {},
resources_config: dict = {},
graph=None,
controllers_config: dict = {},
bridges=[],
without_host: bool = False,
**kwargs
):
if backend == "ros":
# 假设 ros_main, simple_main, automancer_main 是不同 backend 的启动函数
from unilabos.ros.main_slave_run import main, slave # 如果选择 'ros' 作为 backend
elif backend == 'simple':
# 这里假设 simple_backend 和 automancer_backend 是你定义的其他两个后端
# from simple_backend import main as simple_main
pass
elif backend == 'automancer':
# from automancer_backend import main as automancer_main
pass
else:
raise ValueError(f"Unsupported backend: {backend}")
backend_thread = threading.Thread(
target=main if not without_host else slave,
args=(devices_config, resources_config, graph, controllers_config, bridges)
)
backend_thread.start()
logger.info(f"Backend {backend} started.")

34
unilabos/app/controler.py Normal file
View File

@@ -0,0 +1,34 @@
import json
import uuid
from unilabos.app.model import JobAddReq, JobData
from unilabos.ros.nodes.presets.host_node import HostNode
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_kwargs = req.data["action_kwargs"]
req.data['action'] = action_name
if action_name == "execute_command_from_outer":
action_kwargs = {"command": json.dumps(action_kwargs)}
print(f"job_add:{req.device_id} {action_name} {action_kwargs}")
HostNode.get_instance().send_goal(req.device_id, action_name=action_name, action_kwargs=action_kwargs, goal_uuid=req.job_id)
return JobData(jobId=req.job_id)

155
unilabos/app/main.py Normal file
View File

@@ -0,0 +1,155 @@
import argparse
import os
import signal
import sys
import json
import yaml
from copy import deepcopy
# 首先添加项目根目录到路径
current_dir = os.path.dirname(os.path.abspath(__file__))
ilabos_dir = os.path.dirname(os.path.dirname(current_dir))
if ilabos_dir not in sys.path:
sys.path.append(ilabos_dir)
from unilabos.config.config import load_config, BasicConfig
from unilabos.utils.banner_print import print_status, print_unilab_banner
def parse_args():
"""解析命令行参数"""
parser = argparse.ArgumentParser(description="Start Uni-Lab Edge server.")
parser.add_argument("-g", "--graph", help="Physical setup graph.")
parser.add_argument("-d", "--devices", help="Devices config file.")
parser.add_argument("-r", "--resources", help="Resources config file.")
parser.add_argument("-c", "--controllers", default=None, help="Controllers config file.")
parser.add_argument(
"--registry_path",
type=str,
default=None,
action="append",
help="Path to the registry",
)
parser.add_argument(
"--backend",
choices=["ros", "simple", "automancer"],
default="ros",
help="Choose the backend to run with: 'ros', 'simple', or 'automancer'.",
)
parser.add_argument(
"--app_bridges",
nargs="+",
default=["mqtt", "fastapi"],
help="Bridges to connect to. Now support 'mqtt' and 'fastapi'.",
)
parser.add_argument(
"--without_host",
action="store_true",
help="Run the backend as slave (without host).",
)
parser.add_argument(
"--slave_no_host",
action="store_true",
help="Slave模式下跳过等待host服务",
)
parser.add_argument(
"--config",
type=str,
default=None,
help="配置文件路径,支持.py格式的Python配置文件",
)
return parser.parse_args()
def main():
"""主函数"""
# 解析命令行参数
args = parse_args()
args_dict = vars(args)
# 加载配置文件 - 这里保持最先加载配置的逻辑
if args_dict.get("config"):
config_path = args_dict["config"]
if not os.path.exists(config_path):
print_status(f"配置文件 {config_path} 不存在", "error")
elif not config_path.endswith(".py"):
print_status(f"配置文件 {config_path} 不是Python文件必须以.py结尾", "error")
else:
load_config(config_path)
# 设置BasicConfig参数
BasicConfig.is_host_mode = not args_dict.get("without_host", False)
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
from unilabos.resources.graphio import (
read_node_link_json,
read_graphml,
dict_from_graph,
dict_to_nested_dict,
initialize_resources,
)
from unilabos.app.mq import mqtt_client
from unilabos.registry.registry import build_registry
from unilabos.app.backend import start_backend
from unilabos.web import http_client
from unilabos.web import start_server
# 显示启动横幅
print_unilab_banner(args_dict)
# 注册表
build_registry(args_dict["registry_path"])
if args_dict["graph"] is not None:
import unilabos.resources.graphio as graph_res
graph_res.physical_setup_graph = (
read_node_link_json(args_dict["graph"])
if args_dict["graph"].endswith(".json")
else read_graphml(args_dict["graph"])
)
devices_and_resources = dict_from_graph(graph_res.physical_setup_graph)
args_dict["resources_config"] = initialize_resources(list(deepcopy(devices_and_resources).values()))
args_dict["devices_config"] = dict_to_nested_dict(deepcopy(devices_and_resources), devices_only=False)
# args_dict["resources_config"] = dict_to_tree(devices_and_resources, devices_only=False)
args_dict["graph"] = graph_res.physical_setup_graph
else:
if args_dict["devices"] is None or args_dict["resources"] is None:
print_status("Either graph or devices and resources must be provided.", "error")
sys.exit(1)
args_dict["devices_config"] = json.load(open(args_dict["devices"], encoding="utf-8"))
args_dict["resources_config"] = initialize_resources(
list(json.load(open(args_dict["resources"], encoding="utf-8")).values())
)
print_status(f"{len(args_dict['resources_config'])} Resources loaded:", "info")
for i in args_dict["resources_config"]:
print_status(f"DeviceId: {i['id']}, Class: {i['class']}", "info")
if args_dict["controllers"] is not None:
args_dict["controllers_config"] = yaml.safe_load(open(args_dict["controllers"], encoding="utf-8"))
else:
args_dict["controllers_config"] = None
args_dict["bridges"] = []
if "mqtt" in args_dict["app_bridges"]:
args_dict["bridges"].append(mqtt_client)
if "fastapi" in args_dict["app_bridges"]:
args_dict["bridges"].append(http_client)
if "mqtt" in args_dict["app_bridges"]:
def _exit(signum, frame):
mqtt_client.stop()
sys.exit(0)
signal.signal(signal.SIGINT, _exit)
signal.signal(signal.SIGTERM, _exit)
mqtt_client.start()
start_backend(**args_dict)
start_server()
if __name__ == "__main__":
main()

137
unilabos/app/model.py Normal file
View File

@@ -0,0 +1,137 @@
from pydantic import BaseModel, Field
class RespCode:
Success = 0
ErrorHostNotInit = 2001 # Host node not initialized
ErrorInvalidReq = 2002 # Invalid request data
class DeviceAction(BaseModel):
x: str
y: str
action: str
class Device(BaseModel):
id: str
name: str
action: DeviceAction
class DeviceList(BaseModel):
items: list[Device] = []
page: int
pageSize: int
class DevicesResponse(BaseModel):
code: int
data: DeviceList
class DeviceInfoResponse(BaseModel):
code: int
data: Device
class PageResp(BaseModel):
item: list = []
page: int = 1
pageSize: int = 10
class Resp(BaseModel):
code: int = RespCode.Success
data: dict = {}
message: str = "success"
class JobAddReq(BaseModel):
device_id: str = Field(examples=["Gripper"], description="device id")
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}])
job_id: str = Field(examples=["sfsfsfeq"], description="goal uuid")
node_id: str = Field(examples=["sfsfsfeq"], description="node uuid")
class JobStepFinishReq(BaseModel):
token: str = Field(examples=["030944"], description="token")
request_time: str = Field(
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
)
data: dict = Field(
examples=[
{
"orderCode": "任务号。字符串",
"orderName": "任务名称。字符串",
"stepName": "步骤名称。字符串",
"stepId": "步骤Id。GUID",
"sampleId": "通量Id。GUID",
"startTime": "开始时间。时间格式",
"endTime": "完成时间。时间格式",
}
]
)
class JobPreintakeFinishReq(BaseModel):
token: str = Field(examples=["030944"], description="token")
request_time: str = Field(
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
)
data: dict = Field(
examples=[
{
"orderCode": "任务号。字符串",
"orderName": "任务名称。字符串",
"sampleId": "通量Id。GUID",
"startTime": "开始时间。时间格式",
"endTime": "完成时间。时间格式",
"Status": "通量状态,0待生产、2进样、10开始、完成20、异常停止-2、人工停止或取消-3",
}
]
)
class JobFinishReq(BaseModel):
token: str = Field(examples=["030944"], description="token")
request_time: str = Field(
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
)
data: dict = Field(
examples=[
{
"orderCode": "任务号。字符串",
"orderName": "任务名称。字符串",
"startTime": "开始时间。时间格式",
"endTime": "完成时间。时间格式",
"status": "通量状态,完成30、异常停止-11、人工停止或取消-12",
"usedMaterials": [
{
"materialId": "物料Id。GUID",
"locationId": "库位Id。GUID",
"typeMode": "物料类型。 样品1、试剂2、耗材0",
"usedQuantity": "使用的数量。 数字",
}
],
}
]
)
class JobData(BaseModel):
jobId: str = Field(examples=["sfsfsfeq"], description="goal uuid")
status: int = Field(
examples=[0, 1],
default=0,
description="0:UNKNOWN, 1:ACCEPTED, 2:EXECUTING, 3:CANCELING, 4:SUCCEEDED, 5:CANCELED, 6:ABORTED",
)
class JobStatusResp(Resp):
data: JobData
class JobAddResp(Resp):
data: JobData

177
unilabos/app/mq.py Normal file
View File

@@ -0,0 +1,177 @@
import json
import time
import uuid
import paho.mqtt.client as mqtt
import ssl, base64, hmac
from hashlib import sha1
import tempfile
import os
from unilabos.config.config import MQConfig
from unilabos.app.controler import devices, job_add
from unilabos.app.model import JobAddReq, JobAddResp
from unilabos.utils import logger
from unilabos.utils.type_check import TypeEncoder
class MQTTClient:
mqtt_disable = True
def __init__(self):
self.mqtt_disable = not MQConfig.lab_id
self.client_id = f"{MQConfig.group_id}@@@{MQConfig.lab_id}{uuid.uuid4()}"
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=self.client_id, protocol=mqtt.MQTTv5)
self._setup_callbacks()
def _setup_callbacks(self):
self.client.on_log = self._on_log
self.client.on_connect = self._on_connect
self.client.on_message = self._on_message
self.client.on_disconnect = self._on_disconnect
def _on_log(self, client, userdata, level, buf):
logger.info(f"[MQTT] log: {buf}")
def _on_connect(self, client, userdata, flags, rc, properties=None):
logger.info("[MQTT] Connected with result code " + str(rc))
client.subscribe(f"labs/{MQConfig.lab_id}/job/start/", 0)
isok, data = devices()
if not isok:
logger.error("[MQTT] on_connect ErrorHostNotInit")
return
def _on_message(self, client, userdata, msg):
logger.info("[MQTT] on_message<<<< " + msg.topic + " " + str(msg.payload))
try:
payload_str = msg.payload.decode("utf-8")
payload_json = json.loads(payload_str)
logger.debug(f"Topic: {msg.topic}")
logger.debug("Payload:", json.dumps(payload_json, indent=2, ensure_ascii=False))
if msg.topic == f"labs/{MQConfig.lab_id}/job/start/":
logger.debug("job_add", type(payload_json), payload_json)
job_req = JobAddReq.model_validate(payload_json)
data = job_add(job_req)
return JobAddResp(data=data)
except json.JSONDecodeError as e:
logger.error(f"[MQTT] JSON 解析错误: {e}")
logger.error(f"[MQTT] Raw message: {msg.payload}")
except Exception as e:
logger.error(f"[MQTT] 处理消息时出错: {e}")
def _on_disconnect(self, client, userdata, rc, reasonCode=None, properties=None):
if rc != 0:
logger.error(f"[MQTT] Unexpected disconnection {rc}")
def _setup_ssl_context(self):
temp_files = []
try:
with tempfile.NamedTemporaryFile(mode="w", delete=False) as ca_temp:
ca_temp.write(MQConfig.ca_content)
temp_files.append(ca_temp.name)
with tempfile.NamedTemporaryFile(mode="w", delete=False) as cert_temp:
cert_temp.write(MQConfig.cert_content)
temp_files.append(cert_temp.name)
with tempfile.NamedTemporaryFile(mode="w", delete=False) as key_temp:
key_temp.write(MQConfig.key_content)
temp_files.append(key_temp.name)
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
context.load_verify_locations(cafile=temp_files[0])
context.load_cert_chain(certfile=temp_files[1], keyfile=temp_files[2])
self.client.tls_set_context(context)
finally:
for temp_file in temp_files:
try:
os.unlink(temp_file)
except:
pass
def start(self):
if self.mqtt_disable:
logger.warning("MQTT is disabled, skipping connection.")
return
userName = f"Signature|{MQConfig.access_key}|{MQConfig.instance_id}"
password = base64.b64encode(
hmac.new(MQConfig.secret_key.encode(), self.client_id.encode(), sha1).digest()
).decode()
self.client.username_pw_set(userName, password)
self._setup_ssl_context()
# 创建连接线程
def connect_thread_func():
try:
self.client.connect(MQConfig.broker_url, MQConfig.port, 60)
self.client.loop_start()
# 添加连接超时检测
max_attempts = 5
attempt = 0
while not self.client.is_connected() and attempt < max_attempts:
logger.info(
f"[MQTT] 正在连接到 {MQConfig.broker_url}:{MQConfig.port},尝试 {attempt+1}/{max_attempts}"
)
time.sleep(3)
attempt += 1
if self.client.is_connected():
logger.info(f"[MQTT] 已成功连接到 {MQConfig.broker_url}:{MQConfig.port}")
else:
logger.error(f"[MQTT] 连接超时,可能是账号密码错误或网络问题")
self.client.loop_stop()
except Exception as e:
logger.error(f"[MQTT] 连接失败: {str(e)}")
connect_thread_func()
# connect_thread = threading.Thread(target=connect_thread_func)
# connect_thread.daemon = True
# connect_thread.start()
def stop(self):
if self.mqtt_disable:
return
self.client.disconnect()
self.client.loop_stop()
def publish_device_status(self, device_status: dict, device_id, property_name):
# status = device_status.get(device_id, {})
if self.mqtt_disable:
return
status = {"data": device_status.get(device_id, {}), "device_id": device_id}
address = f"labs/{MQConfig.lab_id}/devices"
self.client.publish(address, json.dumps(status), qos=2)
logger.critical(f"Device status published: address: {address}, {status}")
def publish_job_status(self, feedback_data: dict, job_id: str, status: str):
if self.mqtt_disable:
return
jobdata = {"job_id": job_id, "data": feedback_data, "status": status}
self.client.publish(f"labs/{MQConfig.lab_id}/job/list/", json.dumps(jobdata), qos=2)
def publish_registry(self, device_id: str, device_info: dict):
if self.mqtt_disable:
return
address = f"labs/{MQConfig.lab_id}/registry/"
registry_data = json.dumps({device_id: device_info}, ensure_ascii = False, cls = TypeEncoder)
self.client.publish(address, registry_data, qos=2)
logger.debug(f"Registry data published: address: {address}, {registry_data}")
def publish_actions(self, action_id: str, action_info: dict):
if self.mqtt_disable:
return
address = f"labs/{MQConfig.lab_id}/actions/"
action_type_name = action_info["title"]
action_info["title"] = action_id
action_data = json.dumps({action_type_name: action_info}, ensure_ascii=False)
self.client.publish(address, action_data, qos=2)
logger.debug(f"Action data published: address: {address}, {action_data}")
mqtt_client = MQTTClient()
if __name__ == "__main__":
mqtt_client.start()

231
unilabos/app/oss_upload.py Normal file
View File

@@ -0,0 +1,231 @@
import argparse
import os
import time
from typing import Dict, Optional, Tuple
import requests
from unilabos.config.config import OSSUploadConfig
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]:
"""
初始化上传过程
Args:
file_path: 本地文件路径
oss_path: OSS目标路径
filename: 文件名如果为None则使用file_path的文件名
process_key: 处理键
device_id: 设备ID
expires_hours: 链接过期小时数
Returns:
(成功标志, 响应数据)
"""
if filename is None:
filename = os.path.basename(file_path)
# 构造初始化请求
url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.init_endpoint}"
headers = {
"Authorization": OSSUploadConfig.authorization,
"Content-Type": "application/json"
}
payload = {
"device_id": device_id,
"process_key": process_key,
"filename": filename,
"path": oss_path,
"expires_hours": expires_hours
}
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", {})
print(f"初始化上传失败: {response.status_code}, {response.text}")
return False, {}
except Exception as e:
print(f"初始化上传异常: {str(e)}")
return False, {}
def _put_upload(file_path: str, upload_url: str) -> bool:
"""
执行PUT上传
Args:
file_path: 本地文件路径
upload_url: 上传URL
Returns:
是否成功
"""
try:
with open(file_path, "rb") as f:
response = requests.put(upload_url, data=f)
if response.status_code == 200:
return True
print(f"PUT上传失败: {response.status_code}, {response.text}")
return False
except Exception as e:
print(f"PUT上传异常: {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:
"""
文件上传主函数,包含重试机制
Args:
file_path: 本地文件路径
oss_path: OSS目标路径
filename: 文件名如果为None则使用file_path的文件名
process_key: 处理键
device_id: 设备ID
Returns:
是否成功上传
"""
max_retries = OSSUploadConfig.max_retries
retry_count = 0
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
)
if not init_success:
print(f"初始化上传失败,重试 {retry_count + 1}/{max_retries}")
retry_count += 1
time.sleep(1) # 等待1秒后重试
continue
# 获取UUID和上传URL
uuid = init_data.get("uuid")
upload_url = init_data.get("upload_url")
if not uuid or not upload_url:
print(f"初始化上传返回数据不完整,重试 {retry_count + 1}/{max_retries}")
retry_count += 1
time.sleep(1)
continue
# 步骤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}")
retry_count += 1
time.sleep(1)
continue
# 所有步骤都成功
print(f"文件 {file_path} 上传成功")
return True
except Exception as e:
print(f"上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}")
retry_count += 1
time.sleep(1)
print(f"文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}")
return False
if __name__ == "__main__":
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt
# 命令行参数解析
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='处理键')
args = parser.parse_args()
# 检查文件是否存在
if not os.path.exists(args.file):
print(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)
# 执行上传
success = oss_upload(
file_path=args.file,
oss_path=args.path,
filename=None, # 使用默认文件名
process_key=args.process,
device_id=args.device
)
# 输出结果
if success:
print("\n√ 文件上传成功!")
exit(0)
else:
print("\n× 文件上传失败!")
exit(1)

View File

@@ -0,0 +1,19 @@
from unilabos.messages import *
from .pump_protocol import generate_pump_protocol, generate_pump_protocol_with_rinsing
from .clean_protocol import generate_clean_protocol
from .separate_protocol import generate_separate_protocol
from .evaporate_protocol import generate_evaporate_protocol
from .evacuateandrefill_protocol import generate_evacuateandrefill_protocol
from .agv_transfer_protocol import generate_agv_transfer_protocol
# Define a dictionary of protocol generators.
action_protocol_generators = {
PumpTransferProtocol: generate_pump_protocol_with_rinsing,
CleanProtocol: generate_clean_protocol,
SeparateProtocol: generate_separate_protocol,
EvaporateProtocol: generate_evaporate_protocol,
EvacuateAndRefillProtocol: generate_evacuateandrefill_protocol,
AGVTransferProtocol: generate_agv_transfer_protocol,
}
# End Protocols

View File

@@ -0,0 +1,53 @@
import networkx as nx
def generate_agv_transfer_protocol(
G: nx.Graph,
from_repo: dict,
from_repo_position: str,
to_repo: dict = {},
to_repo_position: str = ""
):
from_repo_ = list(from_repo.values())[0]
to_repo_ = list(to_repo.values())[0]
resource_to_move = from_repo_["children"].pop(from_repo_position)
resource_to_move["parent"] = to_repo_["id"]
to_repo_["children"][to_repo_position] = resource_to_move
from_repo_id = from_repo_["id"]
to_repo_id = to_repo_["id"]
wf_list = {
("AiChemEcoHiWo", "zhixing_agv"): {"nav_command" : '{"target" : "LM14"}',
"arm_command": '{"task_name" : "camera/250111_biaozhi.urp"}'},
("AiChemEcoHiWo", "AGV"): {"nav_command" : '{"target" : "LM14"}',
"arm_command": '{"task_name" : "camera/250111_biaozhi.urp"}'},
("zhixing_agv", "Revvity"): {"nav_command" : '{"target" : "LM13"}',
"arm_command": '{"task_name" : "camera/250111_put_board.urp"}'},
("AGV", "Revvity"): {"nav_command" : '{"target" : "LM13"}',
"arm_command": '{"task_name" : "camera/250111_put_board.urp"}'},
("Revvity", "HPLC"): {"nav_command": '{"target" : "LM13"}',
"arm_command": '{"task_name" : "camera/250111_hplc.urp"}'},
("HPLC", "Revvity"): {"nav_command": '{"target" : "LM13"}',
"arm_command": '{"task_name" : "camera/250111_lfp.urp"}'},
}
return [
{
"device_id": "zhixing_agv",
"action_name": "send_nav_task",
"action_kwargs": {
"command": wf_list[(from_repo_id, to_repo_id)]["nav_command"]
}
},
{
"device_id": "zhixing_ur_arm",
"action_name": "move_pos_task",
"action_kwargs": {
"command": wf_list[(from_repo_id, to_repo_id)]["arm_command"]
}
}
]

View File

@@ -0,0 +1,62 @@
import numpy as np
import networkx as nx
def generate_clean_protocol(
G: nx.DiGraph,
vessel: str, # Vessel to clean.
solvent: str, # Solvent to clean vessel with.
volume: float = 25000.0, # Optional. Volume of solvent to clean vessel with.
temp: float = 25, # Optional. Temperature to heat vessel to while cleaning.
repeats: int = 1, # Optional. Number of cleaning cycles to perform.
) -> list[dict]:
"""
Generate a protocol to clean a vessel with a solvent.
:param G: Directed graph. Nodes are containers and pumps, edges are fluidic connections.
:param vessel: Vessel to clean.
:param solvent: Solvent to clean vessel with.
:param volume: Volume of solvent to clean vessel with.
:param temp: Temperature to heat vessel to while cleaning.
:param repeats: Number of cleaning cycles to perform.
:return: List of actions to clean vessel.
"""
# 生成泵操作的动作序列
pump_action_sequence = []
from_vessel = f"flask_{solvent}"
waste_vessel = f"waste_workup"
transfer_flowrate = flowrate = 2500.0
# 生成泵操作的动作序列
for i in range(repeats):
# 单泵依次执行阀指令、活塞指令,将液体吸入与之相连的第一台泵
pump_action_sequence.extend([
{
"device_id": "",
"action_name": "PumpTransferProtocol",
"action_kwargs": {
"from_vessel": from_vessel,
"to_vessel": vessel,
"volume": volume,
"time": volume / flowrate,
# "transfer_flowrate": transfer_flowrate,
}
}
])
pump_action_sequence.extend([
{
"device_id": "",
"action_name": "PumpTransferProtocol",
"action_kwargs": {
"from_vessel": vessel,
"to_vessel": waste_vessel,
"volume": volume,
"time": volume / flowrate,
# "transfer_flowrate": transfer_flowrate,
}
}
])
return pump_action_sequence

View File

@@ -0,0 +1,143 @@
import numpy as np
import networkx as nx
def generate_evacuateandrefill_protocol(
G: nx.DiGraph,
vessel: str,
gas: str,
repeats: int = 1
) -> list[dict]:
"""
生成泵操作的动作序列。
:param G: 有向图, 节点为容器和注射泵, 边为流体管道, A→B边的属性为管道接A端的阀门位置
:param from_vessel: 容器A
:param to_vessel: 容器B
:param volume: 转移的体积
:param flowrate: 最终注入容器B时的流速
:param transfer_flowrate: 泵骨架中转移流速(若不指定,默认与注入流速相同)
:return: 泵操作的动作序列
"""
# 生成电磁阀、真空泵、气源操作的动作序列
vacuum_action_sequence = []
nodes = G.nodes(data=True)
# 找到和 vessel 相连的电磁阀和真空泵、气源
vacuum_backbone = {"vessel": vessel}
for neighbor in G.neighbors(vessel):
if nodes[neighbor]["class"].startswith("solenoid_valve"):
for neighbor2 in G.neighbors(neighbor):
if neighbor2 == vessel:
continue
if nodes[neighbor2]["class"].startswith("vacuum_pump"):
vacuum_backbone.update({"vacuum_valve": neighbor, "pump": neighbor2})
break
elif nodes[neighbor2]["class"].startswith("gas_source"):
vacuum_backbone.update({"gas_valve": neighbor, "gas": neighbor2})
break
# 判断是否设备齐全
if len(vacuum_backbone) < 5:
print(f"\n\n\n{vacuum_backbone}\n\n\n")
raise ValueError("Not all devices are connected to the vessel.")
# 生成操作的动作序列
for i in range(repeats):
# 打开真空泵阀门、关闭气源阀门
vacuum_action_sequence.append([
{
"device_id": vacuum_backbone["vacuum_valve"],
"action_name": "set_valve_position",
"action_kwargs": {
"command": "OPEN"
}
},
{
"device_id": vacuum_backbone["gas_valve"],
"action_name": "set_valve_position",
"action_kwargs": {
"command": "CLOSED"
}
}
])
# 打开真空泵、关闭气源
vacuum_action_sequence.append([
{
"device_id": vacuum_backbone["pump"],
"action_name": "set_status",
"action_kwargs": {
"command": "ON"
}
},
{
"device_id": vacuum_backbone["gas"],
"action_name": "set_status",
"action_kwargs": {
"command": "OFF"
}
}
])
vacuum_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 60}})
# 关闭真空泵阀门、打开气源阀门
vacuum_action_sequence.append([
{
"device_id": vacuum_backbone["vacuum_valve"],
"action_name": "set_valve_position",
"action_kwargs": {
"command": "CLOSED"
}
},
{
"device_id": vacuum_backbone["gas_valve"],
"action_name": "set_valve_position",
"action_kwargs": {
"command": "OPEN"
}
}
])
# 关闭真空泵、打开气源
vacuum_action_sequence.append([
{
"device_id": vacuum_backbone["pump"],
"action_name": "set_status",
"action_kwargs": {
"command": "OFF"
}
},
{
"device_id": vacuum_backbone["gas"],
"action_name": "set_status",
"action_kwargs": {
"command": "ON"
}
}
])
vacuum_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 60}})
# 关闭气源
vacuum_action_sequence.append(
{
"device_id": vacuum_backbone["gas"],
"action_name": "set_status",
"action_kwargs": {
"command": "OFF"
}
}
)
# 关闭阀门
vacuum_action_sequence.append(
{
"device_id": vacuum_backbone["gas_valve"],
"action_name": "set_valve_position",
"action_kwargs": {
"command": "CLOSED"
}
}
)
return vacuum_action_sequence

View File

@@ -0,0 +1,81 @@
import numpy as np
import networkx as nx
def generate_evaporate_protocol(
G: nx.DiGraph,
vessel: str,
pressure: float,
temp: float,
time: float,
stir_speed: float
) -> list[dict]:
"""
Generate a protocol to evaporate a solution from a vessel.
:param G: Directed graph. Nodes are containers and pumps, edges are fluidic connections.
:param vessel: Vessel to clean.
:param solvent: Solvent to clean vessel with.
:param volume: Volume of solvent to clean vessel with.
:param temp: Temperature to heat vessel to while cleaning.
:param repeats: Number of cleaning cycles to perform.
:return: List of actions to clean vessel.
"""
# 生成泵操作的动作序列
pump_action_sequence = []
reactor_volume = 500000.0
transfer_flowrate = flowrate = 2500.0
# 开启冷凝器
pump_action_sequence.append({
"device_id": "rotavap_chiller",
"action_name": "set_temperature",
"action_kwargs": {
"command": "-40"
}
})
# TODO: 通过温度反馈改为 HeatChillToTemp而非等待固定时间
pump_action_sequence.append({
"action_name": "wait",
"action_kwargs": {
"time": 1800
}
})
# 开启旋蒸真空泵、旋转在液体转移后运行time时间
pump_action_sequence.append({
"device_id": "rotavap_controller",
"action_name": "set_pump_time",
"action_kwargs": {
"command": str(time + reactor_volume / flowrate * 3)
}
})
pump_action_sequence.append({
"device_id": "rotavap_controller",
"action_name": "set_pump_time",
"action_kwargs": {
"command": str(time + reactor_volume / flowrate * 3)
}
})
# 液体转入旋转蒸发器
pump_action_sequence.append({
"device_id": "",
"action_name": "PumpTransferProtocol",
"action_kwargs": {
"from_vessel": vessel,
"to_vessel": "rotavap",
"volume": reactor_volume,
"time": reactor_volume / flowrate,
# "transfer_flowrate": transfer_flowrate,
}
})
pump_action_sequence.append({
"action_name": "wait",
"action_kwargs": {
"time": time
}
})
return pump_action_sequence

View File

@@ -0,0 +1,213 @@
import numpy as np
import networkx as nx
def generate_pump_protocol(
G: nx.DiGraph,
from_vessel: str,
to_vessel: str,
volume: float,
flowrate: float = 500.0,
transfer_flowrate: float = 0,
) -> list[dict]:
"""
生成泵操作的动作序列。
:param G: 有向图, 节点为容器和注射泵, 边为流体管道, A→B边的属性为管道接A端的阀门位置
:param from_vessel: 容器A
:param to_vessel: 容器B
:param volume: 转移的体积
:param flowrate: 最终注入容器B时的流速
:param transfer_flowrate: 泵骨架中转移流速(若不指定,默认与注入流速相同)
:return: 泵操作的动作序列
"""
# 生成泵操作的动作序列
pump_action_sequence = []
nodes = G.nodes(data=True)
# 从from_vessel到to_vessel的最短路径
shortest_path = nx.shortest_path(G, source=from_vessel, target=to_vessel)
print(shortest_path)
pump_backbone = shortest_path
if not from_vessel.startswith("pump"):
pump_backbone = pump_backbone[1:]
if not to_vessel.startswith("pump"):
pump_backbone = pump_backbone[:-1]
if transfer_flowrate == 0:
transfer_flowrate = flowrate
min_transfer_volume = min([nodes[pump]["max_volume"] for pump in pump_backbone])
repeats = int(np.ceil(volume / min_transfer_volume))
if repeats > 1 and (from_vessel.startswith("pump") or to_vessel.startswith("pump")):
raise ValueError("Cannot transfer volume larger than min_transfer_volume between two pumps.")
volume_left = volume
# 生成泵操作的动作序列
for i in range(repeats):
# 单泵依次执行阀指令、活塞指令,将液体吸入与之相连的第一台泵
if not from_vessel.startswith("pump"):
pump_action_sequence.extend([
{
"device_id": pump_backbone[0],
"action_name": "set_valve_position",
"action_kwargs": {
"command": G.get_edge_data(pump_backbone[0], from_vessel)["port"][pump_backbone[0]]
}
},
{
"device_id": pump_backbone[0],
"action_name": "set_position",
"action_kwargs": {
"position": float(min(volume_left, min_transfer_volume)),
"max_velocity": transfer_flowrate
}
}
])
pump_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 5}})
for pumpA, pumpB in zip(pump_backbone[:-1], pump_backbone[1:]):
# 相邻两泵同时切换阀门至连通位置
pump_action_sequence.append([
{
"device_id": pumpA,
"action_name": "set_valve_position",
"action_kwargs": {
"command": G.get_edge_data(pumpA, pumpB)["port"][pumpA]
}
},
{
"device_id": pumpB,
"action_name": "set_valve_position",
"action_kwargs": {
"command": G.get_edge_data(pumpB, pumpA)["port"][pumpB],
}
}
])
# 相邻两泵液体转移泵A排出液体泵B吸入液体
pump_action_sequence.append([
{
"device_id": pumpA,
"action_name": "set_position",
"action_kwargs": {
"position": 0.0,
"max_velocity": transfer_flowrate
}
},
{
"device_id": pumpB,
"action_name": "set_position",
"action_kwargs": {
"position": float(min(volume_left, min_transfer_volume)),
"max_velocity": transfer_flowrate
}
}
])
pump_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 5}})
if not to_vessel.startswith("pump"):
# 单泵依次执行阀指令、活塞指令将最后一台泵液体缓慢加入容器B
pump_action_sequence.extend([
{
"device_id": pump_backbone[-1],
"action_name": "set_valve_position",
"action_kwargs": {
"command": G.get_edge_data(pump_backbone[-1], to_vessel)["port"][pump_backbone[-1]]
}
},
{
"device_id": pump_backbone[-1],
"action_name": "set_position",
"action_kwargs": {
"position": 0.0,
"max_velocity": flowrate
}
}
])
pump_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 5}})
volume_left -= min_transfer_volume
return pump_action_sequence
# Pump protocol compilation
def generate_pump_protocol_with_rinsing(
G: nx.DiGraph,
from_vessel: str,
to_vessel: str,
volume: float,
amount: str = "",
time: float = 0,
viscous: bool = False,
rinsing_solvent: str = "air",
rinsing_volume: float = 5000.0,
rinsing_repeats: int = 2,
solid: bool = False,
flowrate: float = 2500.0,
transfer_flowrate: float = 500.0,
) -> list[dict]:
"""
Generates a pump protocol for transferring a specified volume between vessels, including rinsing steps with a chosen solvent. This function constructs a sequence of pump actions based on the provided parameters and the shortest path in a directed graph.
Args:
G (nx.DiGraph): The directed graph representing the vessels and connections. 有向图, 节点为容器和注射泵, 边为流体管道, A→B边的属性为管道接A端的阀门位置
from_vessel (str): The name of the vessel to transfer from.
to_vessel (str): The name of the vessel to transfer to.
volume (float): The volume to transfer.
amount (str, optional): Additional amount specification (default is "").
time (float, optional): Time over which to perform the transfer (default is 0).
viscous (bool, optional): Indicates if the fluid is viscous (default is False).
rinsing_solvent (str, optional): The solvent to use for rinsing (default is "air").
rinsing_volume (float, optional): The volume of rinsing solvent to use (default is 5000.0).
rinsing_repeats (int, optional): The number of times to repeat rinsing (default is 2).
solid (bool, optional): Indicates if the transfer involves a solid (default is False).
flowrate (float, optional): The flow rate for the transfer (default is 2500.0). 最终注入容器B时的流速
transfer_flowrate (float, optional): The flow rate for the transfer action (default is 500.0). 泵骨架中转移流速(若不指定,默认与注入流速相同)
Returns:
list[dict]: A sequence of pump actions to be executed for the transfer and rinsing process. 泵操作的动作序列.
Raises:
AssertionError: If the number of rinsing solvents does not match the number of rinsing repeats.
Examples:
pump_protocol = generate_pump_protocol_with_rinsing(G, "vessel_A", "vessel_B", 100.0, rinsing_solvent="water")
"""
air_vessel = "flask_air"
waste_vessel = f"waste_workup"
shortest_path = nx.shortest_path(G, source=from_vessel, target=to_vessel)
pump_backbone = shortest_path[1: -1]
nodes = G.nodes(data=True)
min_transfer_volume = float(min([nodes[pump]["max_volume"] for pump in pump_backbone]))
if time != 0:
flowrate = transfer_flowrate = volume / time
pump_action_sequence = generate_pump_protocol(G, from_vessel, to_vessel, float(volume), flowrate, transfer_flowrate)
if rinsing_solvent != "air":
if "," in rinsing_solvent:
rinsing_solvents = rinsing_solvent.split(",")
assert len(rinsing_solvents) == rinsing_repeats, "Number of rinsing solvents must match number of rinsing repeats."
else:
rinsing_solvents = [rinsing_solvent] * rinsing_repeats
for rinsing_solvent in rinsing_solvents:
solvent_vessel = f"flask_{rinsing_solvent}"
# 清洗泵
pump_action_sequence.extend(
generate_pump_protocol(G, solvent_vessel, pump_backbone[0], min_transfer_volume, flowrate, transfer_flowrate) +
generate_pump_protocol(G, pump_backbone[0], pump_backbone[-1], min_transfer_volume, flowrate, transfer_flowrate) +
generate_pump_protocol(G, pump_backbone[-1], waste_vessel, min_transfer_volume, flowrate, transfer_flowrate)
)
# 如果转移的是溶液,第一种冲洗溶剂请选用溶液的溶剂,稀释泵内、转移管道内的溶液。后续冲洗溶剂不需要此操作。
if rinsing_solvent == rinsing_solvents[0]:
pump_action_sequence.extend(generate_pump_protocol(G, solvent_vessel, from_vessel, rinsing_volume, flowrate, transfer_flowrate))
pump_action_sequence.extend(generate_pump_protocol(G, solvent_vessel, to_vessel, rinsing_volume, flowrate, transfer_flowrate))
pump_action_sequence.extend(generate_pump_protocol(G, air_vessel, solvent_vessel, rinsing_volume, flowrate, transfer_flowrate))
pump_action_sequence.extend(generate_pump_protocol(G, air_vessel, waste_vessel, rinsing_volume, flowrate, transfer_flowrate))
pump_action_sequence.extend(generate_pump_protocol(G, air_vessel, from_vessel, rinsing_volume, flowrate, transfer_flowrate) * 2)
pump_action_sequence.extend(generate_pump_protocol(G, air_vessel, to_vessel, rinsing_volume, flowrate, transfer_flowrate) * 2)
return pump_action_sequence
# End Protocols

View File

@@ -0,0 +1,230 @@
import numpy as np
import networkx as nx
def generate_separate_protocol(
G: nx.DiGraph,
purpose: str, # 'wash' or 'extract'. 'wash' means that product phase will not be the added solvent phase, 'extract' means product phase will be the added solvent phase. If no solvent is added just use 'extract'.
product_phase: str, # 'top' or 'bottom'. Phase that product will be in.
from_vessel: str, #Contents of from_vessel are transferred to separation_vessel and separation is performed.
separation_vessel: str, # Vessel in which separation of phases will be carried out.
to_vessel: str, # Vessel to send product phase to.
waste_phase_to_vessel: str, # Optional. Vessel to send waste phase to.
solvent: str, # Optional. Solvent to add to separation vessel after contents of from_vessel has been transferred to create two phases.
solvent_volume: float = 50000, # Optional. Volume of solvent to add.
through: str = "", # Optional. Solid chemical to send product phase through on way to to_vessel, e.g. 'celite'.
repeats: int = 1, # Optional. Number of separations to perform.
stir_time: float = 30, # Optional. Time stir for after adding solvent, before separation of phases.
stir_speed: float = 300, # Optional. Speed to stir at after adding solvent, before separation of phases.
settling_time: float = 300 # Optional. Time
) -> list[dict]:
"""
Generate a protocol to clean a vessel with a solvent.
:param G: Directed graph. Nodes are containers and pumps, edges are fluidic connections.
:param vessel: Vessel to clean.
:param solvent: Solvent to clean vessel with.
:param volume: Volume of solvent to clean vessel with.
:param temp: Temperature to heat vessel to while cleaning.
:param repeats: Number of cleaning cycles to perform.
:return: List of actions to clean vessel.
"""
# 生成泵操作的动作序列
pump_action_sequence = []
reactor_volume = 500000.0
waste_vessel = waste_phase_to_vessel
# TODO通过物料管理系统找到溶剂的容器
if "," in solvent:
solvents = solvent.split(",")
assert len(solvents) == repeats, "Number of solvents must match number of repeats."
else:
solvents = [solvent] * repeats
# TODO: 通过设备连接图找到分离容器的控制器、底部出口
separator_controller = f"{separation_vessel}_controller"
separation_vessel_bottom = f"flask_{separation_vessel}"
transfer_flowrate = flowrate = 2500.0
if from_vessel != separation_vessel:
pump_action_sequence.append(
{
"device_id": "",
"action_name": "PumpTransferProtocol",
"action_kwargs": {
"from_vessel": from_vessel,
"to_vessel": separation_vessel,
"volume": reactor_volume,
"time": reactor_volume / flowrate,
# "transfer_flowrate": transfer_flowrate,
}
}
)
# for i in range(2):
# pump_action_sequence.append(
# {
# "device_id": "",
# "action_name": "CleanProtocol",
# "action_kwargs": {
# "vessel": from_vessel,
# "solvent": "H2O", # Solvent to clean vessel with.
# "volume": solvent_volume, # Optional. Volume of solvent to clean vessel with.
# "temp": 25.0, # Optional. Temperature to heat vessel to while cleaning.
# "repeats": 1
# }
# }
# )
# pump_action_sequence.append(
# {
# "device_id": "",
# "action_name": "CleanProtocol",
# "action_kwargs": {
# "vessel": from_vessel,
# "solvent": "CH2Cl2", # Solvent to clean vessel with.
# "volume": solvent_volume, # Optional. Volume of solvent to clean vessel with.
# "temp": 25.0, # Optional. Temperature to heat vessel to while cleaning.
# "repeats": 1
# }
# }
# )
# 生成泵操作的动作序列
for i in range(repeats):
# 找到当次萃取所用溶剂
solvent_thistime = solvents[i]
solvent_vessel = f"flask_{solvent_thistime}"
pump_action_sequence.append(
{
"device_id": "",
"action_name": "PumpTransferProtocol",
"action_kwargs": {
"from_vessel": solvent_vessel,
"to_vessel": separation_vessel,
"volume": solvent_volume,
"time": solvent_volume / flowrate,
# "transfer_flowrate": transfer_flowrate,
}
}
)
pump_action_sequence.extend([
# 搅拌、静置
{
"device_id": separator_controller,
"action_name": "stir",
"action_kwargs": {
"stir_time": stir_time,
"stir_speed": stir_speed,
"settling_time": settling_time
}
},
# 分液(判断电导突跃)
{
"device_id": separator_controller,
"action_name": "valve_open",
"action_kwargs": {
"command": "delta > 0.05"
}
}
])
if product_phase == "bottom":
# 产物转移到目标瓶
pump_action_sequence.append(
{
"device_id": "",
"action_name": "PumpTransferProtocol",
"action_kwargs": {
"from_vessel": separation_vessel_bottom,
"to_vessel": to_vessel,
"volume": 250000.0,
"time": 250000.0 / flowrate,
# "transfer_flowrate": transfer_flowrate,
}
}
)
# 放出上面那一相60秒后关阀门
pump_action_sequence.append(
{
"device_id": separator_controller,
"action_name": "valve_open",
"action_kwargs": {
"command": "time > 60"
}
}
)
# 弃去上面那一相进废液
pump_action_sequence.append(
{
"device_id": "",
"action_name": "PumpTransferProtocol",
"action_kwargs": {
"from_vessel": separation_vessel_bottom,
"to_vessel": waste_vessel,
"volume": 250000.0,
"time": 250000.0 / flowrate,
# "transfer_flowrate": transfer_flowrate,
}
}
)
elif product_phase == "top":
# 弃去下面那一相进废液
pump_action_sequence.append(
{
"device_id": "",
"action_name": "PumpTransferProtocol",
"action_kwargs": {
"from_vessel": separation_vessel_bottom,
"to_vessel": waste_vessel,
"volume": 250000.0,
"time": 250000.0 / flowrate,
# "transfer_flowrate": transfer_flowrate,
}
}
)
# 放出上面那一相
pump_action_sequence.append(
{
"device_id": separator_controller,
"action_name": "valve_open",
"action_kwargs": {
"command": "time > 60"
}
}
)
# 产物转移到目标瓶
pump_action_sequence.append(
{
"device_id": "",
"action_name": "PumpTransferProtocol",
"action_kwargs": {
"from_vessel": separation_vessel_bottom,
"to_vessel": to_vessel,
"volume": 250000.0,
"time": 250000.0 / flowrate,
# "transfer_flowrate": transfer_flowrate,
}
}
)
elif product_phase == "organic":
pass
# 如果不是最后一次,从中转瓶转移回分液漏斗
if i < repeats - 1:
pump_action_sequence.append(
{
"device_id": "",
"action_name": "PumpTransferProtocol",
"action_kwargs": {
"from_vessel": to_vessel,
"to_vessel": separation_vessel,
"volume": 250000.0,
"time": 250000.0 / flowrate,
# "transfer_flowrate": transfer_flowrate,
}
}
)
return pump_action_sequence

View File

123
unilabos/config/config.py Normal file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python
# coding=utf-8
# 定义配置变量和加载函数
import traceback
import os
import importlib.util
from unilabos.utils import logger
class BasicConfig:
ENV = "pro" # 'test'
config_path = ""
is_host_mode = True # 从registry.py移动过来
slave_no_host = False # 是否跳过rclient.wait_for_service()
# MQTT配置
class MQConfig:
lab_id = ""
instance_id = ""
access_key = ""
secret_key = ""
group_id = ""
broker_url = ""
port = 1883
ca_content = ""
cert_content = ""
key_content = ""
# 指定
ca_file = ""
cert_file = ""
key_file = ""
# 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"
# 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",
]
def _update_config_from_module(module):
for name, obj in globals().items():
if isinstance(obj, type) and name.endswith("Config"):
if hasattr(module, name) and isinstance(getattr(module, name), type):
for attr in dir(getattr(module, name)):
if not attr.startswith("_"):
setattr(obj, attr, getattr(getattr(module, name), attr))
# 更新OSS认证
if len(OSSUploadConfig.authorization) == 0:
OSSUploadConfig.authorization = f"lab {MQConfig.lab_id}"
# 对 ca_file cert_file key_file 进行初始化
if len(MQConfig.ca_content) == 0:
# 需要先判断是否为相对路径
if MQConfig.ca_file.startswith("."):
MQConfig.ca_file = os.path.join(BasicConfig.config_path, MQConfig.ca_file)
with open(MQConfig.ca_file, "r", encoding="utf-8") as f:
MQConfig.ca_content = f.read()
if len(MQConfig.cert_content) == 0:
# 需要先判断是否为相对路径
if MQConfig.cert_file.startswith("."):
MQConfig.cert_file = os.path.join(BasicConfig.config_path, MQConfig.cert_file)
with open(MQConfig.cert_file, "r", encoding="utf-8") as f:
MQConfig.cert_content = f.read()
if len(MQConfig.key_content) == 0:
# 需要先判断是否为相对路径
if MQConfig.key_file.startswith("."):
MQConfig.key_file = os.path.join(BasicConfig.config_path, MQConfig.key_file)
with open(MQConfig.key_file, "r", encoding="utf-8") as f:
MQConfig.key_content = f.read()
def load_config(config_path=None):
# 如果提供了配置文件路径,从该文件导入配置
if config_path:
BasicConfig.config_path = os.path.abspath(os.path.dirname(config_path))
if not os.path.exists(config_path):
logger.error(f"配置文件 {config_path} 不存在")
return
try:
module_name = "lab_" + os.path.basename(config_path).replace(".py", "")
spec = importlib.util.spec_from_file_location(module_name, config_path)
if spec is None:
logger.error(f"配置文件 {config_path} 错误")
return
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # type: ignore
_update_config_from_module(module)
logger.info(f"配置文件 {config_path} 加载成功")
except Exception as e:
logger.error(f"加载配置文件 {config_path} 失败: {e}")
traceback.print_exc()
exit(1)
else:
try:
import unilabos.config.local_config as local_config # type: ignore
_update_config_from_module(local_config)
logger.info("已加载默认配置 unilabos.config.local_config")
except ImportError:
pass

View File

@@ -0,0 +1 @@
from .eis_model import EISModelBasedController

View File

@@ -0,0 +1,5 @@
import numpy as np
def EISModelBasedController(eis: np.array) -> float:
return 0.0

View File

View File

@@ -0,0 +1,537 @@
import json
import time
import traceback
from typing import Any, Union, List, Dict, Callable, Optional, Tuple
from pydantic import BaseModel
from pymodbus.client import ModbusSerialClient, ModbusTcpClient
from pymodbus.framer import FramerType
from typing import TypedDict
from unilabos.device_comms.modbus_plc.node.modbus import DeviceType, HoldRegister, Coil, InputRegister, DiscreteInputs, DataType, WorderOrder
from unilabos.device_comms.modbus_plc.node.modbus import Base as ModbusNodeBase
from unilabos.device_comms.universal_driver import UniversalDriver
from unilabos.utils.log import logger
import pandas as pd
class ModbusNode(BaseModel):
name: str
device_type: DeviceType
address: int
data_type: DataType = DataType.INT16
slave: int = 1
class PLCWorkflow(BaseModel):
name: str
actions: List[
Union[
"PLCWorkflow",
Callable[
[Callable[[str], ModbusNodeBase]],
None
]]
]
class Action(BaseModel):
name: str
rw: bool # read是0 write是1
class WorkflowAction(BaseModel):
init: Optional[Callable[[Callable[[str], ModbusNodeBase]], bool]] = None
start: Optional[Callable[[Callable[[str], ModbusNodeBase]], bool]] = None
stop: Optional[Callable[[Callable[[str], ModbusNodeBase]], bool]] = None
cleanup: Optional[Callable[[Callable[[str], ModbusNodeBase]], None]] = None
class ModbusWorkflow(BaseModel):
name: str
actions: List[Union["ModbusWorkflow", WorkflowAction]]
""" 前后端Json解析用 """
class AddressFunctionJson(TypedDict):
func_name: str
node_name: str
mode: str
value: Any
class InitFunctionJson(AddressFunctionJson):
pass
class StartFunctionJson(AddressFunctionJson):
pass
class StopFunctionJson(AddressFunctionJson):
pass
class CleanupFunctionJson(AddressFunctionJson):
pass
class ActionJson(TypedDict):
address_function_to_create: list[AddressFunctionJson]
create_init_function: Optional[InitFunctionJson]
create_start_function: Optional[StartFunctionJson]
create_stop_function: Optional[StopFunctionJson]
create_cleanup_function: Optional[CleanupFunctionJson]
class WorkflowCreateJson(TypedDict):
name: str
action: list[Union[ActionJson, 'WorkflowCreateJson'] | str]
class ExecuteProcedureJson(TypedDict):
register_node_list_from_csv_path: Optional[dict[str, Any]]
create_flow: list[WorkflowCreateJson]
execute_flow: list[str]
class BaseClient(UniversalDriver):
client: Optional[Union[ModbusSerialClient, ModbusTcpClient]] = None
_node_registry: Dict[str, ModbusNodeBase] = {}
DEFAULT_ADDRESS_PATH = ""
def __init__(self):
super().__init__()
# self.register_node_list_from_csv_path()
def _set_client(self, client: Optional[Union[ModbusSerialClient, ModbusTcpClient]]) -> None:
if client is None:
raise ValueError('client is not valid')
# if not isinstance(client, TCPClient ) or not isinstance(client, RTUClient):
# raise ValueError('client is not valid')
self.client = client
def _connect(self) -> None:
logger.info('try to connect client...')
if self.client:
if self.client.connect():
logger.info('client connected!')
else:
logger.error('client connect failed')
else:
raise ValueError('client is not initialized')
@classmethod
def load_csv(cls, file_path: str):
df = pd.read_csv(file_path)
df = df.drop_duplicates(subset='Name', keep='first') # FIXME: 重复的数据应该报错
data_dict = df.set_index('Name').to_dict(orient='index')
nodes = []
for k, v in data_dict.items():
deviceType = v.get('DeviceType', None)
addr = v.get('Address', 0)
dataType = v.get('DataType', 'BOOL')
if not deviceType or not addr:
continue
if deviceType == DeviceType.COIL.value:
byteAddr = int(addr / 10)
bitAddr = addr % 10
addr = byteAddr * 8 + bitAddr
if dataType == 'BOOL':
# noinspection PyTypeChecker
dataType = 'INT16'
# noinspection PyTypeChecker
if pd.isna(dataType):
print(v, "没有注册成功!")
continue
dataType: DataType = DataType[dataType]
nodes.append(ModbusNode(name=k, device_type=DeviceType(deviceType), address=addr, data_type=dataType))
return nodes
def use_node(self, name: str) -> ModbusNodeBase:
if name not in self._node_registry:
raise ValueError(f'node {name} is not registered')
return self._node_registry[name]
def get_node_registry(self) -> Dict[str, ModbusNodeBase]:
return self._node_registry
def register_node_list_from_csv_path(self, path: str = None) -> "BaseClient":
if path is None:
path = self.DEFAULT_ADDRESS_PATH
nodes = self.load_csv(path)
return self.register_node_list(nodes)
def register_node_list(self, node_list: List[ModbusNode]) -> "BaseClient":
if not self.client:
raise ValueError('client is not connected')
if not node_list or len(node_list) == 0:
logger.warning('node list is empty')
return self
logger.info(f'start to register {len(node_list)} nodes...')
for node in node_list:
if node is None:
continue
if node.name in self._node_registry:
logger.info(f'node {node.name} already exists')
exist = self._node_registry[node.name]
if exist.type != node.device_type:
raise ValueError(f'node {node.name} type {node.device_type} is diplicated with {exist.type}')
if exist.address != node.address:
raise ValueError(f'node {node.name} address is duplicated with {exist.address}')
continue
if not isinstance(node.device_type, DeviceType):
raise ValueError(f'node {node.name} type is not valid')
if node.device_type == DeviceType.HOLD_REGISTER:
self._node_registry[node.name] = HoldRegister(self.client, node.name, node.address, node.data_type)
elif node.device_type == DeviceType.COIL:
self._node_registry[node.name] = Coil(self.client, node.name, node.address, node.data_type)
elif node.device_type == DeviceType.INPUT_REGISTER:
self._node_registry[node.name] = InputRegister(self.client, node.name, node.address, node.data_type)
elif node.device_type == DeviceType.DISCRETE_INPUTS:
self._node_registry[node.name] = DiscreteInputs(self.client, node.name, node.address, node.data_type)
else:
raise ValueError(f'node {node.name} type {node.device_type} is not valid')
logger.info('register nodes done.')
return self
def run_plc_workflow(self, workflow: PLCWorkflow) -> None:
if not self.client:
raise ValueError('client is not connected')
logger.info(f'start to run workflow {workflow.name}...')
for action in workflow.actions:
if isinstance(action, PLCWorkflow):
self.run_plc_workflow(action)
elif isinstance(action, Callable):
action(self.use_node)
else:
raise ValueError(f'invalid action {action}')
def call_lifecycle_fn(
self,
workflow: ModbusWorkflow,
fn: Optional[Callable[[Callable], bool]],
) -> bool:
if not fn:
raise ValueError('fn is not valid in call_lifecycle_fn')
try:
return fn(self.use_node)
except Exception as e:
traceback.print_exc()
logger.error(f'execute {workflow.name} lifecycle failed, err: {e}')
return False
def run_modbus_workflow(self, workflow: ModbusWorkflow) -> bool:
if not self.client:
raise ValueError('client is not connected')
logger.info(f'start to run workflow {workflow.name}...')
for action in workflow.actions:
if isinstance(action, ModbusWorkflow):
if self.run_modbus_workflow(action):
logger.info(f"{action.name} workflow done.")
continue
else:
logger.error(f"{action.name} workflow failed")
return False
elif isinstance(action, WorkflowAction):
init = action.init
start = action.start
stop = action.stop
cleanup = action.cleanup
if not init and not start and not stop:
raise ValueError(f'invalid action {action}')
is_err = False
try:
if init and not self.call_lifecycle_fn(workflow, init):
raise ValueError(f"{workflow.name} init action failed")
if not self.call_lifecycle_fn(workflow, start):
raise ValueError(f"{workflow.name} start action failed")
if not self.call_lifecycle_fn(workflow, stop):
raise ValueError(f"{workflow.name} stop action failed")
logger.info(f"{workflow.name} action done.")
except Exception as e:
is_err = True
traceback.print_exc()
logger.error(f"{workflow.name} action failed, err: {e}")
finally:
logger.info(f"{workflow.name} try to run cleanup")
if cleanup:
self.call_lifecycle_fn(workflow, cleanup)
else:
logger.info(f"{workflow.name} cleanup is not defined")
if is_err:
return False
return True
else:
raise ValueError(f'invalid action type {type(action)}')
return True
function_name: dict[str, Callable[[Callable[[str], ModbusNodeBase]], bool]] = {}
@classmethod
def pack_func(cls, func, value="UNDEFINED"):
def execute_pack_func(use_node: Callable[[str], ModbusNodeBase]):
if value == "UNDEFINED":
func()
else:
func(use_node, value)
return execute_pack_func
def create_address_function(self, func_name: str = None, node_name: str = None, mode: str = None, value: Any = None, data_type: Optional[DataType] = None, word_order: WorderOrder = None, slave: Optional[int] = None) -> Callable[[Callable[[str], ModbusNodeBase]], bool]:
def execute_address_function(use_node: Callable[[str], ModbusNodeBase]) -> Union[bool, Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]]:
param = {"value": value}
if data_type is not None:
param["data_type"] = data_type
if word_order is not None:
param["word_order"] = word_order
if slave is not None:
param["slave"] = slave
target_node = use_node(node_name)
print("执行", node_name, type(target_node).__name__, target_node.address, mode, value)
if mode == 'read':
return use_node(node_name).read(**param)
elif mode == 'write':
return not use_node(node_name).write(**param)
return False
if func_name is None:
func_name = node_name + '_' + mode + '_' + str(value)
print("创建 address function", mode, func_name)
self.function_name[func_name] = execute_address_function
return execute_address_function
def create_init_function(self, func_name: str = None, node_name: str = None, mode: str = None, value: Any = None, data_type: Optional[DataType] = None, word_order: WorderOrder = None, slave: Optional[int] = None):
return self.create_address_function(func_name, node_name, mode, value, data_type, word_order, slave)
def create_stop_function(self, func_name: str = None, node_name: str = None, mode: str = None, value: Any = None, data_type: Optional[DataType] = None, word_order: WorderOrder = None, slave: Optional[int] = None):
return self.create_address_function(func_name, node_name, mode, value, data_type, word_order, slave)
def create_cleanup_function(self, func_name: str = None, node_name: str = None, mode: str = None, value: Any = None, data_type: Optional[DataType] = None, word_order: WorderOrder = None, slave: Optional[int] = None):
return self.create_address_function(func_name, node_name, mode, value, data_type, word_order, slave)
def create_start_function(self, func_name: str, write_functions: list[str], condition_functions: list[str], stop_condition_expression: str):
def execute_start_function(use_node: Callable[[str], ModbusNodeBase]) -> bool:
for write_function in write_functions:
self.function_name[write_function](use_node)
while True:
next_loop = False
condition_source = {}
for condition_function in condition_functions:
read_res, read_err = self.function_name[condition_function](use_node)
if read_err:
next_loop = True
break
condition_source[condition_function] = read_res
if not next_loop:
if stop_condition_expression:
condition_source["__RESULT"] = None
exec(f"__RESULT = {stop_condition_expression}", {}, condition_source) # todo: safety check
res = condition_source["__RESULT"]
print("取得计算结果;", res)
if res:
break
else:
time.sleep(0.3)
return True
return execute_start_function
def create_action_from_json(self, data: ActionJson):
for i in data["address_function_to_create"]:
self.create_address_function(**i)
init = None
start = None
stop = None
cleanup = None
if data["create_init_function"]:
print("创建 init function")
init = self.create_init_function(**data["create_init_function"])
if data["create_start_function"]:
print("创建 start function")
start = self.create_start_function(**data["create_start_function"])
if data["create_stop_function"]:
print("创建 stop function")
stop = self.create_stop_function(**data["create_stop_function"])
if data["create_cleanup_function"]:
print("创建 cleanup function")
cleanup = self.create_cleanup_function(**data["create_cleanup_function"])
return WorkflowAction(init=init, start=start, stop=stop, cleanup=cleanup)
workflow_name = {}
def create_workflow_from_json(self, data: list[WorkflowCreateJson]):
for ind, flow in enumerate(data):
print("正在创建 workflow", ind, flow["name"])
actions = []
for i in flow["action"]:
if isinstance(i, str):
print("沿用 已有workflow 作为action", i)
action = self.workflow_name[i]
else:
print("创建 action")
action = self.create_action_from_json(i)
actions.append(action)
flow_instance = ModbusWorkflow(name=flow["name"], actions=actions)
print("创建完成 workflow", flow["name"])
self.workflow_name[flow["name"]] = flow_instance
def execute_workflow_from_json(self, data: list[str]):
for i in data:
print("正在执行 workflow", i)
self.run_modbus_workflow(self.workflow_name[i])
def execute_procedure_from_json(self, data: ExecuteProcedureJson):
if data["register_node_list_from_csv_path"]:
print("注册节点 csv", data["register_node_list_from_csv_path"])
self.register_node_list_from_csv_path(**data["register_node_list_from_csv_path"])
print("创建工作流")
self.create_workflow_from_json(data["create_flow"])
print("执行工作流")
self.execute_workflow_from_json(data["execute_flow"])
class TCPClient(BaseClient):
def __init__(self, addr: str, port: int):
super().__init__()
self._set_client(ModbusTcpClient(host=addr, port=port))
# self._connect()
class RTUClient(BaseClient):
def __init__(self, port: str, baudrate: int, timeout: int):
super().__init__()
self._set_client(ModbusSerialClient(framer=FramerType.RTU, port=port, baudrate=baudrate, timeout=timeout))
self._connect()
if __name__ == '__main__':
""" 代码写法① """
def idel_init(use_node: Callable[[str], ModbusNodeBase]) -> bool:
# 修改速度
use_node('M01_idlepos_velocity_rw').write(20.0)
# 修改位置
# use_node('M01_idlepos_position_rw').write(35.22)
return True
def idel_position(use_node: Callable[[str], ModbusNodeBase]) -> bool:
use_node('M01_idlepos_coil_w').write(True)
while True:
pos_idel, idel_err = use_node('M01_idlepos_coil_r').read(1)
pos_stop, stop_err = use_node('M01_manual_stop_coil_r').read(1)
time.sleep(0.5)
if not idel_err and not stop_err and pos_idel[0] and pos_stop[0]:
break
return True
def idel_stop(use_node: Callable[[str], ModbusNodeBase]) -> bool:
use_node('M01_idlepos_coil_w').write(False)
return True
move_idel = ModbusWorkflow(name="测试待机位置", actions=[WorkflowAction(
init=idel_init,
start=idel_position,
stop=idel_stop,
)])
def pipetter_init(use_node: Callable[[str], ModbusNodeBase]) -> bool:
# 修改速度
# use_node('M01_idlepos_velocity_rw').write(10.0)
# 修改位置
# use_node('M01_idlepos_position_rw').write(35.22)
return True
def pipetter_position(use_node: Callable[[str], ModbusNodeBase]) -> bool:
use_node('M01_pipette0_coil_w').write(True)
while True:
pos_idel, isError = use_node('M01_pipette0_coil_r').read(1)
pos_stop, isError = use_node('M01_manual_stop_coil_r').read(1)
time.sleep(0.5)
if pos_idel[0] and pos_stop[0]:
break
return True
def pipetter_stop(use_node: Callable[[str], ModbusNodeBase]) -> bool:
use_node('M01_pipette0_coil_w').write(False)
return True
move_pipetter = ModbusWorkflow(name="测试待机位置", actions=[WorkflowAction(
init=None,
start=pipetter_position,
stop=pipetter_stop,
)])
workflow_test_2 = ModbusWorkflow(name="测试水平移动并停止", actions=[
move_idel,
move_pipetter,
])
# .run_modbus_workflow(move_2_left_workflow)
""" 代码写法② """
# if False:
# modbus_tcp_client_test2 = TCPClient('192.168.3.2', 502)
# modbus_tcp_client_test2.register_node_list_from_csv_path('M01.csv')
# init = modbus_tcp_client_test2.create_init_function('idel_init', 'M01_idlepos_velocity_rw', 'write', 20.0)
#
# modbus_tcp_client_test2.create_address_function('pos_tip', 'M01_idlepos_coil_w', 'write', True)
# modbus_tcp_client_test2.create_address_function('pos_tip_read', 'M01_idlepos_coil_r', 'read', 1)
# modbus_tcp_client_test2.create_address_function('manual_stop', 'M01_manual_stop_coil_r', 'read', 1)
# start = modbus_tcp_client_test2.create_start_function(
# 'idel_position',
# write_functions=[
# 'pos_tip'
# ],
# condition_functions=[
# 'pos_tip_read',
# 'manual_stop'
# ],
# stop_condition_expression='pos_tip_read[0] and manual_stop[0]'
# )
# stop = modbus_tcp_client_test2.create_stop_function('idel_stop', 'M01_idlepos_coil_w', 'write', False)
#
# move_idel = ModbusWorkflow(name="归位", actions=[WorkflowAction(
# init=init,
# start=start,
# stop=stop,
# )])
#
# modbus_tcp_client_test2.create_address_function('pipetter_position', 'M01_pipette0_coil_w', 'write', True)
# modbus_tcp_client_test2.create_address_function('pipetter_position_read', 'M01_pipette0_coil_r', 'read', 1)
# modbus_tcp_client_test2.create_address_function('pipetter_stop_read', 'M01_manual_stop_coil_r', 'read', 1)
# pipetter_position = modbus_tcp_client_test2.create_start_function(
# 'pipetter_start',
# write_functions=[
# 'pipetter_position'
# ],
# condition_functions=[
# 'pipetter_position_read',
# 'pipetter_stop_read'
# ],
# stop_condition_expression='pipetter_position[0] and pipetter_stop_read[0]'
# )
# pipetter_stop = modbus_tcp_client_test2.create_stop_function('pipetter_stop', 'M01_pipette0_coil_w', 'write', False)
#
# move_pipetter = ModbusWorkflow(name="测试待机位置", actions=[WorkflowAction(
# init=None,
# start=pipetter_position,
# stop=pipetter_stop,
# )])
#
# workflow_test_2 = ModbusWorkflow(name="测试水平移动并停止", actions=[
# move_idel,
# move_pipetter,
# ])
#
# workflow_test_2.run_modbus_workflow()
""" 代码写法③ """
with open('example_json.json', 'r', encoding='utf-8') as f:
example_json = json.load(f)
modbus_tcp_client_test2 = TCPClient('127.0.0.1', 5021)
modbus_tcp_client_test2.execute_procedure_from_json(example_json)
# .run_modbus_workflow(move_2_left_workflow)
# init_client(FramerType.SOCKET, "", '192.168.3.2', 502)

View File

@@ -0,0 +1,104 @@
{
"register_node_list_from_csv_path": {
"path": "M01.csv"
},
"create_flow": [
{
"name": "归位",
"action": [
{
"address_function_to_create": [
{
"func_name": "pos_tip",
"node_name": "M01_idlepos_coil_w",
"mode": "write",
"value": true
},
{
"func_name": "pos_tip_read",
"node_name": "M01_idlepos_coil_r",
"mode": "read",
"value": 1
},
{
"func_name": "manual_stop",
"node_name": "M01_manual_stop_coil_r",
"mode": "read",
"value": 1
}
],
"create_init_function": {
"func_name": "idel_init",
"node_name": "M01_idlepos_velocity_rw",
"mode": "write",
"value": 20.0
},
"create_start_function": {
"func_name": "idel_position",
"write_functions": ["pos_tip"],
"condition_functions": ["pos_tip_read", "manual_stop"],
"stop_condition_expression": "pos_tip_read[0] and manual_stop[0]"
},
"create_stop_function": {
"func_name": "idel_stop",
"node_name": "M01_idlepos_coil_w",
"mode": "write",
"value": false
},
"create_cleanup_function": null
}
]
},
{
"name": "测试待机位置",
"action": [
{
"address_function_to_create": [
{
"func_name": "pipetter_position",
"node_name": "M01_pipette0_coil_w",
"mode": "write",
"value": true
},
{
"func_name": "pipetter_position_read",
"node_name": "M01_pipette0_coil_r",
"mode": "read",
"value": 1
},
{
"func_name": "pipetter_stop_read",
"node_name": "M01_manual_stop_coil_r",
"mode": "read",
"value": 1
}
],
"create_init_function": null,
"create_start_function": {
"func_name": "pipetter_start",
"write_functions": ["pipetter_position"],
"condition_functions": ["pipetter_position_read", "pipetter_stop_read"],
"stop_condition_expression": "pipetter_position_read[0] and pipetter_stop_read[0]"
},
"create_stop_function": {
"func_name": "pipetter_stop",
"node_name": "M01_pipette0_coil_w",
"mode": "write",
"value": false
},
"create_cleanup_function": null
}
]
},
{
"name": "归位并测试待机位置",
"action": [
"归位",
"测试待机位置"
]
}
],
"execute_flow": [
"归位并测试待机位置"
]
}

View File

@@ -0,0 +1,161 @@
# coding=utf-8
from enum import Enum
from abc import ABC, abstractmethod
from pymodbus.client import ModbusBaseSyncClient
from pymodbus.client.mixin import ModbusClientMixin
from typing import Tuple, Union, Optional
DataType = ModbusClientMixin.DATATYPE
class WorderOrder(Enum):
BIG = "big"
LITTLE = "little"
class DeviceType(Enum):
COIL = 'coil'
DISCRETE_INPUTS = 'discrete_inputs'
HOLD_REGISTER = 'hold_register'
INPUT_REGISTER = 'input_register'
class Base(ABC):
def __init__(self, client: ModbusBaseSyncClient, name: str, address: int, typ: DeviceType, data_type: DataType):
self._address: int = address
self._client = client
self._name = name
self._type = typ
self._data_type = data_type
@abstractmethod
def read(self, value, data_type: Optional[DataType] = None, word_order: WorderOrder = WorderOrder.BIG, slave = 1,) -> Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]:
pass
@abstractmethod
def write(self, value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool:
pass
@property
def type(self) -> DeviceType:
return self._type
@property
def address(self) -> int:
return self._address
@property
def name(self) -> str:
return self._name
class Coil(Base):
def __init__(self, client,name, address: int, data_type: DataType):
super().__init__(client, name, address, DeviceType.COIL, data_type)
def read(self, value, data_type: Optional[DataType] = None, word_order: WorderOrder = WorderOrder.BIG, slave = 1,) -> Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]:
resp = self._client.read_coils(
address = self.address,
count = value,
slave = slave)
return resp.bits, resp.isError()
def write(self,value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType ]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool:
if isinstance(value, list):
for v in value:
if not isinstance(v, bool):
raise ValueError(f'value invalidate: {value}')
return self._client.write_coils(
address = self.address,
values = [bool(v) for v in value],
slave = slave).isError()
else:
return self._client.write_coil(
address = self.address,
value = bool(value),
slave = slave).isError()
class DiscreteInputs(Base):
def __init__(self, client,name, address: int, data_type: DataType):
super().__init__(client, name, address, DeviceType.COIL, data_type)
def read(self, value, data_type: Optional[DataType] = None, word_order: WorderOrder = WorderOrder.BIG, slave = 1,) -> Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]:
if not data_type and not self._data_type:
raise ValueError('data type is required')
if not data_type:
data_type = self._data_type
resp = self._client.read_discrete_inputs(
self.address,
count = value,
slave = slave)
# noinspection PyTypeChecker
return self._client.convert_from_registers(resp.registers, data_type, word_order=word_order.value), resp.isError()
def write(self,value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType ]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool:
raise ValueError('discrete inputs only support read')
class HoldRegister(Base):
def __init__(self, client,name, address: int, data_type: DataType):
super().__init__(client, name, address, DeviceType.COIL, data_type)
def read(self, value, data_type: Optional[DataType] = None, word_order: WorderOrder = WorderOrder.BIG, slave = 1,) -> Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]:
if not data_type and not self._data_type:
raise ValueError('data type is required')
if not data_type:
data_type = self._data_type
resp = self._client.read_holding_registers(
address = self.address,
count = value,
slave = slave)
# noinspection PyTypeChecker
return self._client.convert_from_registers(resp.registers, data_type, word_order=word_order.value), resp.isError()
def write(self,value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType ]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool:
if not data_type and not self._data_type:
raise ValueError('data type is required')
if not data_type:
data_type = self._data_type
if isinstance(value , bool):
if value:
return self._client.write_register(self.address, 1, slave= slave).isError()
else:
return self._client.write_register(self.address, 0, slave= slave).isError()
elif isinstance(value, int):
return self._client.write_register(self.address, value, slave= slave).isError()
else:
# noinspection PyTypeChecker
encoder_resp = self._client.convert_to_registers(value, data_type=data_type, word_order=word_order.value)
return self._client.write_registers(self.address, encoder_resp, slave=slave).isError()
class InputRegister(Base):
def __init__(self, client,name, address: int, data_type: DataType):
super().__init__(client, name, address, DeviceType.COIL, data_type)
def read(self, value, data_type: Optional[DataType] = None, word_order: WorderOrder = WorderOrder.BIG, slave = 1) -> Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]:
if not data_type and not self._data_type:
raise ValueError('data type is required')
if not data_type:
data_type = self._data_type
resp = self._client.read_holding_registers(
address = self.address,
count = value,
slave = slave)
# noinspection PyTypeChecker
return self._client.convert_from_registers(resp.registers, data_type, word_order=word_order.value), resp.isError()
def write(self,value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType ]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool:
raise ValueError('input register only support read')

View File

@@ -0,0 +1,37 @@
import modbus_tk.defines as cst
import modbus_tk.modbus_tcp as modbus_tcp
# 创建一个 Modbus TCP 服务器
server = modbus_tcp.TcpServer(
address="127.0.0.1", port=5021, timeout_in_sec=1
)
# 添加一个从设备 (slave)
slave_id = 1
slave = server.add_slave(slave_id)
# 为从设备分配地址空间,例如保持寄存器 (holding registers)
# 假设地址范围为 7000 到 7100对应客户端M01_idlepos_velocity_rw
slave.add_block('hr', cst.HOLDING_REGISTERS, 7000, 100)
slave.add_block('coil_block', cst.COILS, 56000, 1000)
# 初始化地址 56488 和 56432 的值为 True
slave.set_values('coil_block', 56488, [True]) # Coil 56488 设置为 True
slave.set_values('coil_block', 56432, [True]) # Coil 56432 设置为 True
slave.set_values('coil_block', 56496, [True]) # Coil 56488 设置为 True
slave.set_values('coil_block', 56432, [True]) # Coil 56432 设置为 True
# slave.add_block('hr', cst.COILS, 7000, 100)
server.start()
print("Modbus TCP server running on localhost:5021")
# 保持服务器运行,直到按下 Ctrl+C
try:
while True:
pass
except KeyboardInterrupt:
server.stop()
print("Server stopped.")

View File

@@ -0,0 +1,107 @@
import time
from pymodbus.client import ModbusTcpClient
from unilabos.device_comms.modbus_plc.node.modbus import Coil, HoldRegister
from pymodbus.payload import BinaryPayloadDecoder
from pymodbus.constants import Endian
# client = ModbusTcpClient('localhost', port=5020)
client = ModbusTcpClient('192.168.3.2', port=502)
client.connect()
coil1 = Coil(client=client, name='coil_test1', data_type=bool, address=4502*8)
coil1.write(True)
time.sleep(3)
coil1.write(False)
coil1 = Coil(client=client, name='coil_test1', data_type=bool, address=4503*8)
coil1.write(True)
time.sleep(3)
coil1.write(False)
exit(0)
register1 = HoldRegister(client=client, name="test-1", address=7040)
coil1 = Coil(client=client, name='coil_test1', address=7002*8)
coil1.write(True)
while True:
# result = client.read_holding_registers(address=7040, count=2, slave=1) # unit=1 是从站地址
result = register1.read(2, slave=1)
if result.isError():
print("读取失败")
else:
print("读取成功:", result.registers)
decoder = BinaryPayloadDecoder.fromRegisters(
result.registers, byteorder=Endian.BIG, wordorder=Endian.LITTLE
)
real_value = decoder.decode_32bit_float()
print("这里的值是: ", real_value)
if real_value > 42:
coil1.write(False)
break
time.sleep(1)
# # 创建 Modbus TCP 客户端,连接到本地模拟的服务器
# client = ModbusClient('localhost', port=5020)
# # 连接到服务器
# # 读取保持寄存器(地址 0读取 10 个寄存器)
# # address: int,
# # *,
# # count: int = 1,
# # slave: int = 1,
# response = client.read_holding_registers(
# address=0, count=10, slave=1
# )
# response = coil1.read(2, slave=1)
#
# if response.isError():
# print(f"Error reading registers: {response}")
# else:
# print(f"Read holding registers: {response.bits}")
#
# coil1.write(1, slave=1)
# print("Wrote value 1234 to holding register 0")
#
# response = coil1.read(2, slave=1)
# if response.isError():
# print(f"Error reading registers: {response}")
# else:
# print(f"Read holding registers after write: {response.bits}")
#
#
# if response.isError():
# print(f"Error reading registers: {response}")
# else:
# print(f"Read holding registers after write: {response.bits}")
client.close()
# # 写入保持寄存器(地址 0值为 1234
# client.write_register(0, 1234, slave=1)
# print("Wrote value 1234 to holding register 0")
# # 再次读取寄存器,确认写入成功
# response = client.read_holding_registers(address=0, count=10, slave=1)
# if response.isError():
# print(f"Error reading registers: {response}")
# else:
# print(f"Read holding registers after write: {response.registers}")
# # 关闭连接
# client.close()

View File

@@ -0,0 +1,45 @@
# coding=utf-8
from pymodbus.client import ModbusTcpClient
from unilabos.device_comms.modbus_plc.node.modbus import Coil
import time
client = ModbusTcpClient('192.168.3.2', port=502)
client.connect()
coil1 = Coil(client=client, name='0', address=7012*8)
coil2 = Coil(client=client, name='0', address=7062*8)
coil3 = Coil(client=client, name='0', address=7054*8)
while True:
time.sleep(1)
resp, isError = coil2.read(1)
resp1, isError = coil3.read(1)
print(resp[0], resp1[0])
# hr = HoldRegister(client, '1', 100)
# resp = hr.write([666.3, 777.4], data_type=DATATYPE.FLOAT32, word_order=WORDORDER.BIG)
# print('write ===== hr1', resp)
# time.sleep(1)
# h_resp = hr.read(4, data_type=DATATYPE.FLOAT32, word_order=WORDORDER.BIG)
# print('=======hr1', h_resp)
#
#
# resp = hr.write([666, 777], data_type=DATATYPE.INT32, word_order=WORDORDER.BIG)
# print('write ===== hr1', resp)
# time.sleep(1)
# h_resp = hr.read(4, data_type=DATATYPE.INT32, word_order=WORDORDER.BIG)
# print('=======hr1', h_resp)
#
#
# resp = hr.write('hello world!', data_type=DATATYPE.STRING, word_order=WORDORDER.BIG)
# print('write ===== hr1', resp)
# time.sleep(1)
# h_resp = hr.read(12, data_type=DATATYPE.STRING, word_order=WORDORDER.BIG)
# print('=======hr1', h_resp)

View File

@@ -0,0 +1,42 @@
import modbus_tk.modbus_tcp as modbus_tcp
import modbus_tk.defines as cst
from modbus_tk.modbus import Slave
# 创建一个 Modbus TCP 服务器
server = modbus_tcp.TcpServer(
address="localhost", port=5020, timeout_in_sec=1
)
# 设置服务器的地址和端口
# server.set_address("localhost", 5020) # 监听在本地端口5020
# 添加一个从设备Slave ID 设为 1
slave: Slave = server.add_slave(1)
# 向从设备添加一个保持寄存器块假设从地址0开始10个寄存器
# def add_block(self, block_name, block_type, starting_address, size)
# slave.add_block('0', cst.HOLDING_REGISTERS, 0, 10)
# 添加一个线圈
# 0 名字, 从 16 字节内存位置开始,分配连续两个字节的内存大小,注意地址只能是 8 的整数倍
slave.add_block('0', cst.COILS, 2*8, 2)
# 1 名字100 起始地址, 8 是从 100 的位置分配 8 个字节内存, 两个线圈的量
slave.add_block('1', cst.HOLDING_REGISTERS, 100, 8)
slave.add_block('2', cst.HOLDING_REGISTERS, 200, 16)
# slave.add_block('2', cst.DISCRETE_INPUTS , 200, 2)
# slave.add_block('3', cst.ANALOG_INPUTS , 300, 2)
# 启动服务器
server.start()
print("Modbus TCP server running on localhost:5020")
# 保持服务器运行,直到按下 Ctrl+C
try:
while True:
pass
except KeyboardInterrupt:
server.stop()
print("Server stopped.")

View File

@@ -0,0 +1,168 @@
import time
from typing import Callable
from unilabos.device_comms.modbus_plc.client import TCPClient, ModbusWorkflow, WorkflowAction, load_csv
from unilabos.device_comms.modbus_plc.node.modbus import Base as ModbusNodeBase
############ 第一种写法 ##############
# modbus_tcp_client_test1 = TCPClient('192.168.3.2', 502)
#
#
# node_list = [
# ModbusNode(name="left_move_coli", device_type=DeviceType.COIL, address=7003 * 8),
# ModbusNode(name="right_move_coli", device_type=DeviceType.COIL, address=7002 * 8),
# ModbusNode(name="position_register", device_type=DeviceType.HOLD_REGISTER, address=7040),
# ]
#
# def judge_position(node: ModbusNodeBase):
# idx = 0
# while idx <= 5:
# result, is_err = node.read(2)
# if is_err:
# print("读取失败")
# else:
# print("读取成功:", result)
# idx+=1
# time.sleep(1)
#
# workflow_move_2_right = PLCWorkflow(name="测试水平向右移动", actions=[
# lambda use_node: use_node('left_move_coli').write(False),
# lambda use_node: use_node('right_move_coli').write(True),
# lambda use_node: judge_position(use_node('position_register')),
# lambda use_node: use_node('right_move_coli').write(False),
# ])
#
#
# workflow_move_2_left = PLCWorkflow(name="测试水平向左移动", actions=[
# lambda use_node: use_node('right_move_coli').write(False),
# lambda use_node: use_node('left_move_coli').write(True),
# lambda use_node: judge_position(use_node('position_register')),
# lambda use_node: use_node('left_move_coli').write(False),
# ])
#
#
# workflow_test_1 = PLCWorkflow(name="测试水平移动并停止", actions=[
# workflow_move_2_right,
# workflow_move_2_left,
# ])
#
# modbus_tcp_client_test1 \
# .register_node_list(node_list) \
# .run_plc_workflow(workflow_test_1)
#
############ 第二种写法 ##############
modbus_tcp_client_test2 = TCPClient('192.168.3.2', 502)
# def judge_position(node: ModbusNodeBase):
# idx = 0
# while idx <= 5:
# result, is_err = node.read(2)
# if is_err:
# print("读取失败")
# else:
# print("读取成功:", result)
# idx+=1
# time.sleep(1)
# def move_2_right_init(use_node: Callable[[str], ModbusNodeBase]) -> bool:
# use_node('left_move_coli').write(False)
# use_node('right_move_coli').write(True)
# return True
# def move_2_right_start(use_node: Callable[[str], ModbusNodeBase]) -> bool:
# judge_position(use_node('position_register'))
# return True
# def move_2_right_stop(use_node: Callable[[str], ModbusNodeBase]) -> bool:
# use_node('right_move_coli').write(False)
# return True
# move_2_right_workflow = ModbusWorkflow(name="测试水平向右移动", actions=[WorkflowAction(
# init=move_2_right_init,
# start=move_2_right_start,
# stop=move_2_right_stop,
# )])
# move_2_right_workflow = ModbusWorkflow(name="测试水平向右移动", actions=[WorkflowAction(
# init=move_2_right_init,
# start= None,
# stop= None,
# cleanup=None,
# )])
def idel_init(use_node: Callable[[str], ModbusNodeBase]) -> bool:
# 修改速度
use_node('M01_idlepos_velocity_rw').write(20.0)
# 修改位置
# use_node('M01_idlepos_position_rw').write(35.22)
return True
def idel_position(use_node: Callable[[str], ModbusNodeBase]) -> bool:
use_node('M01_idlepos_coil_w').write(True)
while True:
pos_idel, idel_err = use_node('M01_idlepos_coil_r').read(1)
pos_stop, stop_err = use_node('M01_manual_stop_coil_r').read(1)
time.sleep(0.5)
if not idel_err and not stop_err and pos_idel[0] and pos_stop[0]:
break
return True
def idel_stop(use_node: Callable[[str], ModbusNodeBase]) -> bool:
use_node('M01_idlepos_coil_w').write(False)
return True
move_idel= ModbusWorkflow(name="测试待机位置", actions=[WorkflowAction(
init=idel_init,
start=idel_position,
stop=idel_stop,
)])
def pipetter_init(use_node: Callable[[str], ModbusNodeBase]) -> bool:
# 修改速度
# use_node('M01_idlepos_velocity_rw').write(10.0)
# 修改位置
# use_node('M01_idlepos_position_rw').write(35.22)
return True
def pipetter_position(use_node: Callable[[str], ModbusNodeBase]) -> bool:
use_node('M01_pipette0_coil_w').write(True)
while True:
pos_idel, isError = use_node('M01_pipette0_coil_r').read(1)
pos_stop, isError = use_node('M01_manual_stop_coil_r').read(1)
time.sleep(0.5)
if pos_idel[0] and pos_stop[0]:
break
return True
def pipetter_stop(use_node: Callable[[str], ModbusNodeBase]) -> bool:
use_node('M01_pipette0_coil_w').write(False)
return True
move_pipetter= ModbusWorkflow(name="测试待机位置", actions=[WorkflowAction(
init=None,
start=pipetter_position,
stop=pipetter_stop,
)])
workflow_test_2 = ModbusWorkflow(name="测试水平移动并停止", actions=[
move_idel,
move_pipetter,
])
nodes = load_csv('/Users/dingshinn/Desktop/lbg/uni-lab/M01.csv')
modbus_tcp_client_test2 \
.register_node_list(nodes) \
.run_modbus_workflow(workflow_test_2)
# .run_modbus_workflow(move_2_left_workflow)

View File

@@ -0,0 +1,66 @@
import json
import requests
from rclpy.logging import get_logger
class BaseRequest:
def __init__(self):
self._logger = get_logger(__name__)
def get_logger(self):
return self._logger
def get(self, url, params, headers={"Content-Type": "application/json"}):
try:
response = requests.get(url, params=params, headers=headers, timeout=30)
self.get_logger().debug(
f"Request >>> : {params} {response.status_code} {response.text}"
)
if response.status_code == 200:
return response.json()
except Exception as e:
self.get_logger().error(f"Request ERROR: {e}")
return
def post(self, url, params={}, files=None, headers={"Content-Type": "application/json"}):
try:
response = requests.post(
url, data=json.dumps(params) if params else None, headers=headers, timeout=120, files=files
)
self.get_logger().debug(
f"Request >>> : {response.request.body} {response.status_code} {response.text}"
)
if response.status_code == 200:
return response.json()
else:
raise Exception("Request ERROR:", response.text)
except Exception as e:
self.get_logger().error(f"Request ERROR: {e}")
return
def form_post(self, url, params):
try:
response = requests.post(
url=url,
data=params,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=3,
)
self.get_logger().debug(
f"Request >>> : {response.request.body} {response.status_code} {response.text}"
)
if response.status_code == 200:
return response.json()
else:
raise Exception("Request ERROR:", response.text)
except Exception as e:
self.get_logger().error(f"Request ERROR: {e}")
return
# 使用示例
if __name__ == "__main__":
pass

View File

@@ -0,0 +1,228 @@
from abc import abstractmethod
from functools import wraps
import inspect
import json
import logging
import queue
from socket import socket
import threading
import time
import traceback
from typing import Optional
import serial
class SingleRunningExecutor(object):
"""
异步执行单个任务不允许重复执行通过class的函数获得唯一任务名的实例
需要对
"""
__instance = {}
@classmethod
def get_instance(cls, func, post_func=None, name=None, *var, **kwargs):
print(f"!!!get_instance: {name} {kwargs}")
if name is None:
name = func.__name__
if name not in cls.__instance:
cls.__instance[name] = cls(func, post_func, *var, **kwargs)
return cls.__instance[name]
start_time: float = None
end_time: float = None
is_running: bool = None
is_error: bool = None
is_success: bool = None
@property
def is_ended(self):
return not self.is_running and (self.is_error or self.is_success)
@property
def is_started(self):
return self.is_running or self.is_error or self.is_success
def reset(self):
self.start_time = None
self.end_time = None
self.is_running = False
self.is_error = False
self.is_success = False
self._final_var = {}
self._thread = threading.Thread(target=self._execute)
def __init__(self, func, post_func=None, *var, **kwargs):
self._func = func
self._post_func = post_func
self._sig = inspect.signature(self._func)
self._var = var
self._kwargs = kwargs
self.reset()
def _execute(self):
res = None
try:
for ind, i in enumerate(self._var):
self._final_var[list(self._sig.parameters.keys())[ind]] = i
for k, v in self._kwargs.items():
if k in self._sig.parameters.keys():
self._final_var[k] = v
self.is_running = True
print(f"!!!_final_var: {self._final_var}")
res = self._func(**self._final_var)
except Exception as e:
self.is_running = False
self.is_error = True
traceback.print_exc()
if callable(self._post_func):
self._post_func(res, self._final_var)
def start(self, **kwargs):
if len(kwargs) > 0:
self._kwargs = kwargs
self.start_time = time.time()
self._thread.start()
def join(self):
if self.is_running:
self._thread.join()
self.end_time = time.time()
def command(func):
"""
Decorator for command_set execution. Checks if the method is called in the same thread as the class instance,
if so enqueues the command_set and waits for a reply in the reply queue. Else it concludes it must be the command
handler thread and actually executes the method. This way methods in the child classes need to be written
just once and decorated accordingly.
:return: decorated method
"""
@wraps(func)
def wrapper(*args, **kwargs):
device_instance = args[0]
if threading.get_ident() == device_instance.current_thread:
command_set = [func, args, kwargs]
device_instance.command_queue.put(command_set)
while True:
if not device_instance.reply_queue.empty():
return device_instance.reply_queue.get()
else:
return func(*args, **kwargs)
return wrapper
class UniversalDriver(object):
def _init_logger(self):
self.logger = logging.getLogger(f"{self.__class__.__name__}_logger")
def __init__(self):
self._init_logger()
def execute_command_from_outer(self, command: str):
try:
command = json.loads(command.replace("'", '"').replace("False", "false").replace("True", "true")) # 要求不能出现'作为字符串
except Exception as e:
print(f"Json解析失败: {e}")
return False
for k, v in command.items():
print(f"执行{k}方法,参数为{v}")
try:
getattr(self, k)(**v)
except Exception as e:
print(f"执行{k}方法失败: {e}")
traceback.print_exc()
return False
return True
class TransportDriver(UniversalDriver):
COMMAND_QUEUE_ENABLE = True
command_handler_thread: Optional[threading.Thread] = None
__connection: Optional[serial.Serial | socket] = None
def _init_command_queue(self):
self.command_queue = queue.Queue()
self.reply_queue = queue.Queue()
def __command_handler_daemon(self):
while True:
try:
if not self.command_queue.empty():
command_item = self.command_queue.get()
method = command_item[0]
arguments = command_item[1]
keywordarguments = command_item[2]
reply = method(*arguments, **keywordarguments)
self.reply_queue.put(reply)
else:
self.keepalive()
except ValueError as e:
# workaround if something goes wrong with the serial connection
# future me will certainly not hate past me for this...
self.logger.critical(e)
self.__connection.flush()
# thread-safe purging of both queues
while not self.command_queue.empty():
self.command_queue.get()
while not self.reply_queue.empty():
self.reply_queue.get()
def launch_command_handler(self):
if self.COMMAND_QUEUE_ENABLE:
self.command_handler_thread = threading.Thread(target=self.__command_handler_daemon, name="{0}_command_handler".format(self.device_name), daemon=True)
self.command_handler_thread.start()
@abstractmethod
def open_connection(self):
pass
@abstractmethod
def close_connection(self):
pass
@abstractmethod
def keepalive(self):
pass
def __init__(self):
super().__init__()
if self.COMMAND_QUEUE_ENABLE:
self.launch_command_handler()
class DriverChecker(object):
def __init__(self, driver, interval: int | float):
self.driver = driver
self.interval = interval
self._thread = threading.Thread(target=self._monitor)
self._thread.daemon = True
self._stop_event = threading.Event()
def _monitor(self):
while not self._stop_event.is_set():
try:
# print(self.__class__.__name__, "Started!")
self.check()
except Exception as e:
print(f"Error in {self.__class__.__name__}: {str(e)}")
traceback.print_exc()
finally:
time.sleep(self.interval)
@abstractmethod
def check(self):
"""子类必须实现此方法"""
raise NotImplementedError
def start_monitoring(self):
self._thread.start()
def stop_monitoring(self):
self._stop_event.set()
self._thread.join()

View File

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