0.10.7 Update (#101)

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

* delete deprecated mock devices

* rename categories

* combine chromatographic devices

* rename rviz simulation nodes

* organic virtual devices

* parse vessel_id

* run registry completion before merge

---------

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

* fix: workstation handlers and vessel_id parsing

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

* modify default discovery_interval to 15s

* feat: add trace log level

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

* fix: drop_tips not using auto resource select

* fix: discard_tips error

* fix: discard_tips

* fix: prcxi_res

* add: prcxi res
fix: startup slow

* feat: workstation example

* fix pumps and liquid_handler handle

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

* fix all protocol_compilers and remove deprecated devices

* feat: 新增use_remote_resource参数

* fix and remove redundant info

* bugfixes on organic protocols

* fix filter protocol

* fix protocol node

* 临时兼容错误的driver写法

* fix: prcxi import error

* use call_async in all service to avoid deadlock

* fix: figure_resource

* Update recipe.yaml

* add workstation template and battery example

* feat: add sk & ak

* update workstation base

* Create workstation_architecture.md

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

* refactor: ProtocolNode→WorkstationNode

* Add:msgs.action (#83)

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

* Add:msgs.action

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

* simplify resource system

* uncompleted refactor

* example for use WorkstationBase

* feat: websocket

* feat: websocket test

* feat: workstation example

* feat: action status

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

* fix: 还原protocol node处理方法

* fix: build

* fix: missing job_id key

* ws test version 1

* ws test version 2

* ws protocol

* 增加物料关系上传日志

* 增加物料关系上传日志

* 修正物料关系上传

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

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

* 修复event loop错误

* 修复edge上报错误

* 修复async错误

* 更新schema的title字段

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

* 注册表编辑器

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

* 增加addr参数

* fix: addr param

* fix: addr param

* 取消labid 和 强制config输入

* Add action definitions for LiquidHandlerSetGroup and LiquidHandlerTransferGroup

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

* Add LiquidHandlerSetGroup and LiquidHandlerTransferGroup actions to CMakeLists

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

* result_info改为字典类型

* 新增uat的地址替换

* runze multiple pump support

(cherry picked from commit 49354fcf39)

* remove runze multiple software obtainer

(cherry picked from commit 8bcc92a394)

* support multiple backbone

(cherry picked from commit 4771ff2347)

* Update runze pump format

* Correct runze multiple backbone

* Update runze_multiple_backbone

* Correct runze pump multiple receive method.

* Correct runze pump multiple receive method.

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

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

* fix import error

* fix dupe upload registry

* refactor ws client

* add server timeout

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

* fix run_column

* Update run_column_protocol.py

(cherry picked from commit e5aa4d940a)

* resource_update use resource_add

* 新增版位推荐功能

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

* update registry with nested obj

* fix protocol node log_message, added create_resource return value

* fix protocol node log_message, added create_resource return value

* try fix add protocol

* fix resource_add

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

* Feature/xprbalance-zhida (#80)

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

* feat(devices): add mettler_toledo xpr balance

* balance

* 重新补全zhida注册表

* PRCXI9320 json

* PRCXI9320 json

* PRCXI9320 json

* fix resource download

* remove class for resource

* bump version to 0.10.6

* 更新所有注册表

* 修复protocolnode的兼容性

* 修复protocolnode的兼容性

* Update install md

* Add Defaultlayout

* 更新物料接口

* fix dict to tree/nested-dict converter

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

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

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

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

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

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

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

* frontend_docs

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

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

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

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

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

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

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

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

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

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

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

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

---------

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

* 更新物料接口

* Workstation dev yb2 (#100)

* Refactor and extend reaction station action messages

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

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

* 修复to_plr_resources

* add update remove

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

* 修复资源添加

* 修复transfer_resource_to_another生成

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

* 新增test_resource动作

* fix host_node error

* fix host_node test_resource error

* fix host_node test_resource error

* 过滤本地动作

* 移动内部action以兼容host node

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

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

* update todo

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

* pass the tests

* update todo

* add conda-pack-build.yml

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

(cherry picked from commit 172599adcf)

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

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

* Update conda-pack-build.yml

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

* Update conda-pack-build.yml

* Fix FileNotFoundError

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

* Fix unilabos msgs search error

* Fix environment_check.py

* Update recipe.yaml

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

* Fix nested conda pack

* Fix one-key installation path error

* Bump version to 0.10.7

* Workshop bj (#99)

* Add LaiYu Liquid device integration and tests

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

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

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

* add

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

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

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

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

* 修复大小写文件夹名字

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

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

* Update intro.md

* 物料教程

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

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

* Update prcxi driver & fix transfer_liquid mix_times

* fix: correct mix_times type

* Update liquid_handler registry

* test: prcxi.py

* Update registry from pr

* fix ony-key script not exist

* clean files

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>
Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
Co-authored-by: Guangxin Zhang <guangxin.zhang.bio@gmail.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>
Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: LccLink <1951855008@qq.com>
Co-authored-by: lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com>
Co-authored-by: shiyubo0410 <shiyubo@dp.tech>
This commit is contained in:
Xuwznln
2025-10-12 23:34:26 +08:00
committed by GitHub
parent 172599adcf
commit 9aeffebde1
229 changed files with 136969 additions and 17429 deletions

View File

@@ -1,6 +1,6 @@
package:
name: unilabos
version: 0.10.4
version: 0.10.7
source:
path: ../unilabos
@@ -10,7 +10,6 @@ build:
python:
entry_points:
- unilab = unilabos.app.main:main
- unilab-register = unilabos.app.register:main
script:
- set PIP_NO_INDEX=
- if: win
@@ -32,11 +31,14 @@ requirements:
- python ==3.11.11
- pip
- setuptools
- zstd
- zstandard
run:
- conda-forge::python ==3.11.11
- compilers
- cmake
- zstd
- zstandard
- ninja
- if: unix
then:
@@ -61,7 +63,7 @@ requirements:
- uvicorn
- gradio
- flask
- websocket
- websockets
- ipython
- jupyter
- jupyros

View File

@@ -41,11 +41,13 @@ jobs:
defaults:
run:
shell: bash -l {0}
# Windows uses cmd for better conda/mamba compatibility, Unix uses bash
shell: ${{ matrix.platform == 'win-64' && 'cmd /C CALL {0}' || 'bash -el {0}' }}
steps:
- name: Check if platform should be built
id: should_build
shell: bash
run: |
if [[ -z "${{ github.event.inputs.platforms }}" ]]; then
echo "should_build=true" >> $GITHUB_OUTPUT
@@ -61,61 +63,110 @@ jobs:
ref: ${{ github.event.inputs.branch }}
fetch-depth: 0
- name: Setup Miniconda
- name: Setup Miniforge (with mamba)
if: steps.should_build.outputs.should_build == 'true'
uses: conda-incubator/setup-miniconda@v3
with:
miniconda-version: 'latest'
miniforge-version: latest
use-mamba: true
python-version: '3.11.11'
channels: conda-forge,robostack-staging,uni-lab,defaults
channel-priority: strict
channel-priority: flexible
activate-environment: unilab
auto-activate-base: false
auto-activate-base: true
auto-update-conda: false
show-channel-urls: true
- name: Install conda-pack
if: steps.should_build.outputs.should_build == 'true'
- name: Install conda-pack, unilabos and dependencies (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
conda install -c conda-forge conda-pack -y
echo Installing unilabos and dependencies to unilab environment...
echo Using mamba for faster and more reliable dependency resolution...
mamba install uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
- name: Install unilabos and dependencies
if: steps.should_build.outputs.should_build == 'true'
- name: Install conda-pack, unilabos and dependencies (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "Installing unilabos and dependencies to unilab environment..."
conda install uni-lab::unilabos -c uni-lab -c robostack-staging -c conda-forge -y
echo "Using mamba for faster and more reliable dependency resolution..."
mamba install uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
- name: Get latest ros-humble-unilabos-msgs version
if: steps.should_build.outputs.should_build == 'true'
id: msgs_version
- name: Get latest ros-humble-unilabos-msgs version (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
id: msgs_version_win
run: |
INSTALLED_VERSION=$(conda list ros-humble-unilabos-msgs | grep ros-humble-unilabos-msgs | awk '{print $2}')
echo "installed_version=$INSTALLED_VERSION" >> $GITHUB_OUTPUT
echo "Installed ros-humble-unilabos-msgs version: $INSTALLED_VERSION"
echo Checking installed ros-humble-unilabos-msgs version...
conda list ros-humble-unilabos-msgs
for /f "tokens=2" %%i in ('conda list ros-humble-unilabos-msgs --json ^| python -c "import sys, json; pkgs=json.load(sys.stdin); print(pkgs[0]['version'] if pkgs else 'not-found')"') do set VERSION=%%i
echo installed_version=%VERSION% >> %GITHUB_OUTPUT%
echo Installed ros-humble-unilabos-msgs version: %VERSION%
- name: Check for newer ros-humble-unilabos-msgs
if: steps.should_build.outputs.should_build == 'true'
- name: Get latest ros-humble-unilabos-msgs version (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
id: msgs_version_unix
shell: bash
run: |
echo "Checking installed ros-humble-unilabos-msgs version..."
VERSION=$(conda list ros-humble-unilabos-msgs --json | python -c "import sys, json; pkgs=json.load(sys.stdin); print(pkgs[0]['version'] if pkgs else 'not-found')")
echo "installed_version=$VERSION" >> $GITHUB_OUTPUT
echo "Installed ros-humble-unilabos-msgs version: $VERSION"
- name: Check for newer ros-humble-unilabos-msgs (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo Checking for available ros-humble-unilabos-msgs versions...
mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo Search completed
echo.
echo Updating ros-humble-unilabos-msgs to latest version...
mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo Already at latest version
- name: Check for newer ros-humble-unilabos-msgs (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "Checking for available ros-humble-unilabos-msgs versions..."
conda search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge --info || echo "Search completed"
mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo "Search completed"
echo ""
echo "Updating ros-humble-unilabos-msgs to latest version..."
conda update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo "Already at latest version"
mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo "Already at latest version"
- name: Install latest unilabos from source
if: steps.should_build.outputs.should_build == 'true'
- name: Install latest unilabos from source (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo Uninstalling existing unilabos...
pip uninstall unilabos -y || echo unilabos not installed via pip
echo Installing unilabos from source (branch: ${{ github.event.inputs.branch }})...
pip install .
echo Verifying installation...
pip show unilabos
- name: Install latest unilabos from source (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "Uninstalling existing unilabos..."
pip uninstall unilabos -y || echo "unilabos not installed via pip"
echo "Installing unilabos from source (branch: ${{ github.event.inputs.branch }})..."
pip install .
echo "Verifying installation..."
pip show unilabos
- name: Display environment info
if: steps.should_build.outputs.should_build == 'true'
- name: Display environment info (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo === Environment Information ===
conda env list
echo.
echo === Installed Packages ===
conda list | findstr /C:"unilabos" /C:"ros-humble-unilabos-msgs" || conda list
echo.
echo === Python Packages ===
pip list | findstr unilabos || pip list
- name: Display environment info (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "=== Environment Information ==="
conda env list
@@ -126,34 +177,83 @@ jobs:
echo "=== Python Packages ==="
pip list | grep unilabos || pip list
- name: Verify environment integrity
if: steps.should_build.outputs.should_build == 'true'
- name: Verify environment integrity (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo Verifying Python version...
python -c "import sys; print(f'Python version: {sys.version}')"
echo Verifying unilabos import...
python -c "import unilabos; print(f'UniLabOS version: {unilabos.__version__}')" || echo Warning: Could not import unilabos
echo Checking critical packages...
python -c "import rclpy; print('ROS2 rclpy: OK')"
echo Running comprehensive verification script...
python scripts\verify_installation.py || echo Warning: Verification script reported issues
echo Environment verification complete!
- name: Verify environment integrity (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "Verifying Python version..."
python -c "import sys; print(f'Python version: {sys.version}')"
echo "Verifying unilabos import..."
python -c "import unilabos; print(f'UniLabOS version: {unilabos.__version__}')" || echo "Warning: Could not import unilabos"
echo "Checking critical packages..."
python -c "import rclpy; print('ROS2 rclpy: OK')"
echo "Running comprehensive verification script..."
python scripts/verify_installation.py || echo "Warning: Verification script reported issues"
echo "Environment verification complete!"
- name: Pack conda environment
if: steps.should_build.outputs.should_build == 'true'
- name: Pack conda environment (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo Packing unilab environment with conda-pack...
conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
echo Pack file created:
dir unilab-env-${{ matrix.platform }}.tar.gz
- name: Pack conda environment (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "Packing unilab environment with conda-pack..."
conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
echo "Pack file created:"
ls -lh unilab-env-${{ matrix.platform }}.tar.gz
- name: Prepare distribution package (scripts + environment)
if: steps.should_build.outputs.should_build == 'true'
- name: Prepare Windows distribution package
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo ==========================================
echo Creating distribution package...
echo Platform: ${{ matrix.platform }}
echo ==========================================
mkdir dist-package 2>nul || cd .
rem Copy packed environment
echo Adding: unilab-env-${{ matrix.platform }}.tar.gz
copy unilab-env-${{ matrix.platform }}.tar.gz dist-package\
rem Copy installation script
echo Adding: install_unilab.bat
copy scripts\install_unilab.bat dist-package\
rem Copy verification script
echo Adding: verify_installation.py
copy scripts\verify_installation.py dist-package\
rem Create README using Python script
echo Creating: README.txt
python scripts\create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package\README.txt
echo.
echo Distribution package contents:
dir /b dist-package
echo.
- name: Prepare Unix/Linux distribution package
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "=========================================="
echo "Creating distribution package..."
@@ -165,102 +265,62 @@ jobs:
echo "Adding: unilab-env-${{ matrix.platform }}.tar.gz"
cp unilab-env-${{ matrix.platform }}.tar.gz dist-package/
# Copy installation script (platform specific)
if [ "${{ matrix.platform }}" == "win-64" ]; then
echo "Adding: install_unilab.bat"
cp scripts/install_unilab.bat dist-package/
else
echo "Adding: install_unilab.sh"
cp scripts/install_unilab.sh dist-package/
chmod +x dist-package/install_unilab.sh
fi
# Copy installation script
echo "Adding: install_unilab.sh"
cp scripts/install_unilab.sh dist-package/
chmod +x dist-package/install_unilab.sh
# Copy verification script
echo "Adding: verify_installation.py"
cp scripts/verify_installation.py dist-package/
# Create README
# Create README using Python script
echo "Creating: README.txt"
cat > dist-package/README.txt << 'EOFREADME'
UniLabOS Conda-Pack Environment
================================
This package contains a pre-built UniLabOS environment.
Installation Instructions:
--------------------------
Windows:
1. Extract unilab-pack-win-64.zip
2. Double-click install_unilab.bat (or run in cmd)
3. Follow the prompts
macOS/Linux:
1. Extract unilab-pack-{platform}.tar.gz
2. Run: bash install_unilab.sh
3. Follow the prompts
The installation script will:
- Automatically find your conda installation
- Extract the environment to conda's envs/unilab directory
- Run conda-unpack to finalize setup
After installation:
conda activate unilab
python verify_installation.py
Package Contents:
- install_unilab script (automatic installation)
- unilab-env-{platform}.tar.gz (packed environment)
- verify_installation.py (verification tool)
- README.txt (this file)
Branch: ${{ github.event.inputs.branch }}
Platform: ${{ matrix.platform }}
Python: 3.11.11
Build Date: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
EOFREADME
python scripts/create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package/README.txt
echo ""
echo "Distribution package contents:"
ls -lh dist-package/
echo ""
- name: Create final distribution archive (ZIP/TAR.GZ)
if: steps.should_build.outputs.should_build == 'true'
- name: Finalize Windows distribution package
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo ==========================================
echo Windows distribution package ready
echo.
echo Package will be uploaded as artifact
echo GitHub Actions will automatically create ZIP
echo.
echo Contents:
dir /b dist-package
echo.
echo Users will download a ZIP containing:
echo - install_unilab.bat
echo - unilab-env-${{ matrix.platform }}.tar.gz
echo - verify_installation.py
echo - README.txt
echo ==========================================
- name: Create Unix/Linux TAR.GZ archive
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "=========================================="
if [ "${{ matrix.platform }}" == "win-64" ]; then
echo "Creating Windows ZIP archive..."
echo "Archive: unilab-pack-win-64.zip"
echo "Contents: install_unilab.bat + unilab-env-win-64.tar.gz + extras"
cd dist-package
powershell -Command "Compress-Archive -Path * -DestinationPath ../unilab-pack-${{ matrix.platform }}.zip -Force"
cd ..
else
echo "Creating Unix/Linux TAR.GZ archive..."
echo "Archive: unilab-pack-${{ matrix.platform }}.tar.gz"
echo "Contents: install_unilab.sh + unilab-env-${{ matrix.platform }}.tar.gz + extras"
tar -czf unilab-pack-${{ matrix.platform }}.tar.gz -C dist-package .
fi
echo "Creating Unix/Linux TAR.GZ archive..."
echo "Archive: unilab-pack-${{ matrix.platform }}.tar.gz"
echo "Contents: install_unilab.sh + unilab-env-${{ matrix.platform }}.tar.gz + extras"
tar -czf unilab-pack-${{ matrix.platform }}.tar.gz -C dist-package .
echo "=========================================="
echo ""
echo "Final package created:"
ls -lh unilab-pack-*
echo ""
if [ "${{ matrix.platform }}" == "win-64" ]; then
echo "Users can now:"
echo " 1. Download unilab-pack-win-64.zip"
echo " 2. Extract it"
echo " 3. Run install_unilab.bat"
else
echo "Users can now:"
echo " 1. Download unilab-pack-${{ matrix.platform }}.tar.gz"
echo " 2. Extract it: tar -xzf unilab-pack-${{ matrix.platform }}.tar.gz"
echo " 3. Run: bash install_unilab.sh"
fi
echo "Users can now:"
echo " 1. Download unilab-pack-${{ matrix.platform }}.tar.gz"
echo " 2. Extract it: tar -xzf unilab-pack-${{ matrix.platform }}.tar.gz"
echo " 3. Run: bash install_unilab.sh"
echo ""
- name: Upload distribution package
@@ -268,12 +328,32 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
path: unilab-pack-*
path: dist-package/
retention-days: 90
if-no-files-found: error
- name: Display package info
if: steps.should_build.outputs.should_build == 'true'
- name: Display package info (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo ==========================================
echo Build Summary
echo ==========================================
echo Platform: ${{ matrix.platform }}
echo Branch: ${{ github.event.inputs.branch }}
echo Python version: 3.11.11
echo.
echo Distribution package contents:
dir dist-package
echo.
echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
echo.
echo After download, extract the ZIP and run:
echo install_unilab.bat
echo ==========================================
- name: Display package info (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "=========================================="
echo "Build Summary"
@@ -282,19 +362,15 @@ jobs:
echo "Branch: ${{ github.event.inputs.branch }}"
echo "Python version: 3.11.11"
echo ""
echo "Package contents:"
if [ "${{ matrix.platform }}" == "win-64" ]; then
echo " - unilab-pack-${{ matrix.platform }}.zip"
else
echo " - unilab-pack-${{ matrix.platform }}.tar.gz"
fi
echo " - unilab-env-${{ matrix.platform }}.tar.gz (packed environment)"
echo " - install_unilab script"
echo " - verify_installation.py"
echo " - README.txt"
echo "Distribution package contents:"
ls -lh dist-package/
echo ""
echo "Package size:"
ls -lh unilab-pack-* 2>/dev/null || ls -lh unilab-env-${{ matrix.platform }}.tar.gz
echo "Package size (tar.gz):"
ls -lh unilab-pack-*.tar.gz
echo ""
echo "Download the artifact and run the install script!"
echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}"
echo ""
echo "After download:"
echo " - Windows/macOS: Extract ZIP, then: tar -xzf unilab-pack-${{ matrix.platform }}.tar.gz"
echo " - Linux: Extract ZIP (or download tar.gz directly), run install_unilab.sh"
echo "=========================================="

3
.gitignore vendored
View File

@@ -2,6 +2,7 @@ configs/
temp/
output/
unilabos_data/
pyrightconfig.json
## Python
# Byte-compiled / optimized / DLL files
@@ -246,3 +247,5 @@ local_test2.py
ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
*.bz2
test_config.py

15
CONTRIBUTORS Normal file
View File

@@ -0,0 +1,15 @@
156 Xuwznln <18435084+Xuwznln@users.noreply.github.com>
39 Junhan Chang <changjh@dp.tech>
9 wznln <18435084+Xuwznln@users.noreply.github.com>
8 Guangxin Zhang <guangxin.zhang.bio@gmail.com>
5 ZiWei <131428629+ZiWei09@users.noreply.github.com>
2 Junhan Chang <changjh@pku.edu.cn>
2 Xie Qiming <97236197+Andy6M@users.noreply.github.com>
1 Harvey Que <103566763+Mile-Away@users.noreply.github.com>
1 Junhan Chang <1700011741@pku.edu.cn>
1 LccLink <1951855008@qq.com>
1 h840473807 <47357934+h840473807@users.noreply.github.com>
1 lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com>
1 shiyubo0410 <shiyubo@dp.tech>
1 王俊杰 <1800011822@pku.edu.cn>
1 王俊杰 <43375851+wjjxxx@users.noreply.github.com>

View File

@@ -13,18 +13,16 @@
```json
{
"nodes": [
{
"id": "PLR_STATION",
"name": "PLR_LH_TEST",
"parent": null,
"type": "device",
"class": "liquid_handler",
"config": {},
"data": {},
"children": [
"deck"
]
},
{
"id": "PLR_STATION",
"name": "PLR_LH_TEST",
"parent": null,
"type": "device",
"class": "liquid_handler",
"config": {},
"data": {},
"children": ["deck"]
},
{
"id": "deck",
"name": "deck",
@@ -32,12 +30,12 @@
"class": null,
"parent": "PLR_STATION",
"children": [
"trash",
"trash_core96",
"teaching_carrier",
"tip_rack",
"plate"
]
"trash",
"trash_core96",
"teaching_carrier",
"tip_rack",
"plate"
]
}
],
"links": []
@@ -45,6 +43,7 @@
```
配置文件定义了移液站的组成部分,主要包括:
- 移液站本体LiquidHandler- 设备类型
- 移液站携带物料实例deck- 物料类型
@@ -55,7 +54,7 @@
使用以下命令启动移液站设备:
```bash
unilab -g test/experiments/plr_test.json --app_bridges ""
unilab -g test/experiments/plr_test.json --ak [通过网页获取的ak值] --sk [通过网页获取的sk值]
```
### 2. 执行枪头插入操作
@@ -66,35 +65,50 @@ unilab -g test/experiments/plr_test.json --app_bridges ""
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的输出日志。
此命令会通过 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
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
```
也可以登陆网页,给`tip_spots`选择`tip_rack_tipspot_0_0``use_channels``0``offsets`均填写`0`,同样可观察到上面的日志
## 常见问题
1. **重复插入枪头不成功**操作编排应该符合实际操作顺序可自行通过PyLabRobot进行测试
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** - 液体转移
1. **LiquidHandlerProtocolCreation** - 协议创建
2. **LiquidHandlerAspirate** - 液操作
3. **LiquidHandlerDispense** - 排液操作
4. **LiquidHandlerDiscardTips** - 丢弃枪头
5. **LiquidHandlerDropTips** - 卸下枪头
6. **LiquidHandlerDropTips96** - 卸下 96 通道枪头
7. **LiquidHandlerMoveLid** - 移动
8. **LiquidHandlerMovePlate** - 移动板子
9. **LiquidHandlerMoveResource** - 移动资源
10. **LiquidHandlerPickUpTips** - 插入枪头
11. **LiquidHandlerPickUpTips96** - 插入 96 通道枪头
12. **LiquidHandlerReturnTips** - 归还枪头
13. **LiquidHandlerReturnTips96** - 归还 96 通道枪头
14. **LiquidHandlerSetLiquid** - 设置液体
15. **LiquidHandlerSetTipRack** - 设置枪头架
16. **LiquidHandlerStamp** - 打印标记
17. **LiquidHandlerTransfer** - 液体转移
18. **LiquidHandlerSetGroup** - 设置分组
19. **LiquidHandlerTransferBiomek** - Biomek 液体转移
20. **LiquidHandlerIncubateBiomek** - Biomek 孵育
21. **LiquidHandlerMoveBiomek** - Biomek 移动
22. **LiquidHandlerOscillateBiomek** - Biomek 振荡
23. **LiquidHandlerTransferGroup** - 分组转移
24. **LiquidHandlerAdd** - 添加操作
25. **LiquidHandlerMix** - 混合操作
26. **LiquidHandlerMoveTo** - 移动到指定位置
27. **LiquidHandlerRemove** - 移除操作
这些操作可通过ROS2 Action接口进行调用以实现复杂的移液流程。
这些操作可通过 ROS2 Action 接口进行调用,以实现复杂的移液流程。

View File

@@ -19,7 +19,7 @@ Uni-Lab 的组态图当前支持 node-link json 和 graphml 格式,其中包
对用户来说,“直接操作设备执行单个指令”不是个真实需求,真正的需求是**“执行对实验有意义的单个完整动作”——加入某种液体多少量;萃取分液;洗涤仪器等等。就像实验步骤文字书写的那样。**
而这些对实验有意义的单个完整动作,**一般需要多个设备的协同**,还依赖于他们的**物理连接关系(管道相连;机械臂可转运)**。
于是 Uni-Lab 实现了抽象的“工作站”,即注册表中的 `workstation` 设备(`ProtocolNode`类)来处理编译、规划操作。以泵骨架组成的自动有机实验室为例,设备管道连接关系如下:
于是 Uni-Lab 实现了抽象的“工作站”,即注册表中的 `workstation` 设备(`WorkstationNode`类)来处理编译、规划操作。以泵骨架组成的自动有机实验室为例,设备管道连接关系如下:
![topology](image/02-topology-and-chemputer-compile/topology.png)

View File

@@ -1,26 +1,64 @@
## 简单单变量动作函数
### `SendCmd`
```{literalinclude} ../../unilabos_msgs/action/SendCmd.action
:language: yaml
```
----
---
### `StrSingleInput`
```{literalinclude} ../../unilabos_msgs/action/StrSingleInput.action
:language: yaml
```
---
### `IntSingleInput`
```{literalinclude} ../../unilabos_msgs/action/IntSingleInput.action
:language: yaml
```
---
### `FloatSingleInput`
```{literalinclude} ../../unilabos_msgs/action/FloatSingleInput.action
:language: yaml
```
---
### `Point3DSeparateInput`
```{literalinclude} ../../unilabos_msgs/action/Point3DSeparateInput.action
:language: yaml
```
---
### `Wait`
```{literalinclude} ../../unilabos_msgs/action/Wait.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`
@@ -28,7 +66,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab
:language: yaml
```
----
---
### `Evaporate`
@@ -36,7 +74,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab
:language: yaml
```
----
---
### `HeatChill`
@@ -44,7 +82,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab
:language: yaml
```
----
---
### `HeatChillStart`
@@ -52,7 +90,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab
:language: yaml
```
----
---
### `HeatChillStop`
@@ -60,7 +98,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab
:language: yaml
```
----
---
### `PumpTransfer`
@@ -68,7 +106,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab
:language: yaml
```
----
---
### `Separate`
@@ -76,7 +114,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab
:language: yaml
```
----
---
### `Stir`
@@ -84,20 +122,179 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab
:language: yaml
```
----
---
### `Add`
```{literalinclude} ../../unilabos_msgs/action/Add.action
:language: yaml
```
---
### `AddSolid`
```{literalinclude} ../../unilabos_msgs/action/AddSolid.action
:language: yaml
```
---
### `AdjustPH`
```{literalinclude} ../../unilabos_msgs/action/AdjustPH.action
:language: yaml
```
---
### `Centrifuge`
```{literalinclude} ../../unilabos_msgs/action/Centrifuge.action
:language: yaml
```
---
### `CleanVessel`
```{literalinclude} ../../unilabos_msgs/action/CleanVessel.action
:language: yaml
```
---
### `Crystallize`
```{literalinclude} ../../unilabos_msgs/action/Crystallize.action
:language: yaml
```
---
### `Dissolve`
```{literalinclude} ../../unilabos_msgs/action/Dissolve.action
:language: yaml
```
---
### `Dry`
```{literalinclude} ../../unilabos_msgs/action/Dry.action
:language: yaml
```
---
### `Filter`
```{literalinclude} ../../unilabos_msgs/action/Filter.action
:language: yaml
```
---
### `FilterThrough`
```{literalinclude} ../../unilabos_msgs/action/FilterThrough.action
:language: yaml
```
---
### `Hydrogenate`
```{literalinclude} ../../unilabos_msgs/action/Hydrogenate.action
:language: yaml
```
---
### `Purge`
```{literalinclude} ../../unilabos_msgs/action/Purge.action
:language: yaml
```
---
### `Recrystallize`
```{literalinclude} ../../unilabos_msgs/action/Recrystallize.action
:language: yaml
```
---
### `RunColumn`
```{literalinclude} ../../unilabos_msgs/action/RunColumn.action
:language: yaml
```
---
### `StartPurge`
```{literalinclude} ../../unilabos_msgs/action/StartPurge.action
:language: yaml
```
---
### `StartStir`
```{literalinclude} ../../unilabos_msgs/action/StartStir.action
:language: yaml
```
---
### `StopPurge`
```{literalinclude} ../../unilabos_msgs/action/StopPurge.action
:language: yaml
```
---
### `StopStir`
```{literalinclude} ../../unilabos_msgs/action/StopStir.action
:language: yaml
```
---
### `Transfer`
```{literalinclude} ../../unilabos_msgs/action/Transfer.action
:language: yaml
```
---
### `WashSolid`
```{literalinclude} ../../unilabos_msgs/action/WashSolid.action
:language: yaml
```
---
## 移液工作站及相关生物自动化设备操作
Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.org/user_guide/index.html),包含生物实验中常见的操作,如移液、混匀、离心等。
### `LiquidHandlerAspirate`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerAspirate.action
:language: yaml
```
----
---
### `LiquidHandlerDiscardTips`
@@ -105,7 +302,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
:language: yaml
```
----
---
### `LiquidHandlerDispense`
@@ -113,7 +310,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
:language: yaml
```
----
---
### `LiquidHandlerDropTips`
@@ -121,7 +318,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
:language: yaml
```
----
---
### `LiquidHandlerDropTips96`
@@ -129,7 +326,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
:language: yaml
```
----
---
### `LiquidHandlerMoveLid`
@@ -137,7 +334,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
:language: yaml
```
----
---
### `LiquidHandlerMovePlate`
@@ -145,7 +342,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
:language: yaml
```
----
---
### `LiquidHandlerMoveResource`
@@ -153,7 +350,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
:language: yaml
```
----
---
### `LiquidHandlerPickUpTips`
@@ -161,7 +358,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
:language: yaml
```
----
---
### `LiquidHandlerPickUpTips96`
@@ -169,7 +366,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
:language: yaml
```
----
---
### `LiquidHandlerReturnTips`
@@ -177,7 +374,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
:language: yaml
```
----
---
### `LiquidHandlerReturnTips96`
@@ -185,7 +382,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
:language: yaml
```
----
---
### `LiquidHandlerStamp`
@@ -193,7 +390,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
:language: yaml
```
----
---
### `LiquidHandlerTransfer`
@@ -201,9 +398,113 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
:language: yaml
```
----
## 多工作站及小车运行、物料转移
---
### `LiquidHandlerAdd`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerAdd.action
:language: yaml
```
---
### `LiquidHandlerIncubateBiomek`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerIncubateBiomek.action
:language: yaml
```
---
### `LiquidHandlerMix`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMix.action
:language: yaml
```
---
### `LiquidHandlerMoveBiomek`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveBiomek.action
:language: yaml
```
---
### `LiquidHandlerMoveTo`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveTo.action
:language: yaml
```
---
### `LiquidHandlerOscillateBiomek`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerOscillateBiomek.action
:language: yaml
```
---
### `LiquidHandlerProtocolCreation`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerProtocolCreation.action
:language: yaml
```
---
### `LiquidHandlerRemove`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerRemove.action
:language: yaml
```
---
### `LiquidHandlerSetGroup`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerSetGroup.action
:language: yaml
```
---
### `LiquidHandlerSetLiquid`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerSetLiquid.action
:language: yaml
```
---
### `LiquidHandlerSetTipRack`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerSetTipRack.action
:language: yaml
```
---
### `LiquidHandlerTransferBiomek`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransferBiomek.action
:language: yaml
```
---
### `LiquidHandlerTransferGroup`
```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransferGroup.action
:language: yaml
```
---
## 多工作站及小车运行、物料转移
### `AGVTransfer`
@@ -211,7 +512,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
:language: yaml
```
----
---
### `WorkStationRun`
@@ -219,12 +520,64 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o
:language: yaml
```
----
---
### `ResetHandling`
```{literalinclude} ../../unilabos_msgs/action/ResetHandling.action
:language: yaml
```
---
### `ResourceCreateFromOuter`
```{literalinclude} ../../unilabos_msgs/action/ResourceCreateFromOuter.action
:language: yaml
```
---
### `ResourceCreateFromOuterEasy`
```{literalinclude} ../../unilabos_msgs/action/ResourceCreateFromOuterEasy.action
:language: yaml
```
---
### `SetPumpPosition`
```{literalinclude} ../../unilabos_msgs/action/SetPumpPosition.action
:language: yaml
```
---
## 固体分配与处理设备操作
### `SolidDispenseAddPowderTube`
```{literalinclude} ../../unilabos_msgs/action/SolidDispenseAddPowderTube.action
:language: yaml
```
---
## 其他设备操作
### `EmptyIn`
```{literalinclude} ../../unilabos_msgs/action/EmptyIn.action
:language: yaml
```
---
## 机械臂、夹爪等机器人设备
Uni-Lab 机械臂、机器人、夹爪和导航指令集沿用 ROS2 的 `control_msgs` 和 `nav2_msgs`
### `FollowJointTrajectory`
```yaml
@@ -292,7 +645,8 @@ trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_error
```
----
---
### `GripperCommand`
```yaml
@@ -310,17 +664,19 @@ bool reached_goal # True iff the gripper position has reached the commanded setp
```
----
---
### `JointTrajectory`
```yaml
trajectory_msgs/JointTrajectory trajectory
---
---
---
```
----
---
### `PointHead`
```yaml
@@ -330,12 +686,13 @@ string pointing_frame
builtin_interfaces/Duration min_duration
float64 max_velocity
---
---
float64 pointing_angle_error
```
----
---
### `SingleJointPosition`
```yaml
@@ -343,15 +700,16 @@ float64 position
builtin_interfaces/Duration min_duration
float64 max_velocity
---
---
std_msgs/Header header
float64 position
float64 velocity
float64 error
```
----
---
### `AssistedTeleop`
```yaml
@@ -363,10 +721,10 @@ builtin_interfaces/Duration total_elapsed_time
---
#feedback
builtin_interfaces/Duration current_teleop_duration
```
----
---
### `BackUp`
```yaml
@@ -380,10 +738,10 @@ builtin_interfaces/Duration total_elapsed_time
---
#feedback definition
float32 distance_traveled
```
----
---
### `ComputePathThroughPoses`
```yaml
@@ -398,10 +756,10 @@ nav_msgs/Path path
builtin_interfaces/Duration planning_time
---
#feedback definition
```
----
---
### `ComputePathToPose`
```yaml
@@ -416,10 +774,10 @@ nav_msgs/Path path
builtin_interfaces/Duration planning_time
---
#feedback definition
```
----
---
### `DriveOnHeading`
```yaml
@@ -433,10 +791,10 @@ builtin_interfaces/Duration total_elapsed_time
---
#feedback definition
float32 distance_traveled
```
----
---
### `DummyBehavior`
```yaml
@@ -447,10 +805,10 @@ std_msgs/String command
builtin_interfaces/Duration total_elapsed_time
---
#feedback definition
```
----
---
### `FollowPath`
```yaml
@@ -465,10 +823,10 @@ std_msgs/Empty result
#feedback definition
float32 distance_to_goal
float32 speed
```
----
---
### `FollowWaypoints`
```yaml
@@ -480,10 +838,10 @@ int32[] missed_waypoints
---
#feedback definition
uint32 current_waypoint
```
----
---
### `NavigateThroughPoses`
```yaml
@@ -501,10 +859,10 @@ builtin_interfaces/Duration estimated_time_remaining
int16 number_of_recoveries
float32 distance_remaining
int16 number_of_poses_remaining
```
----
---
### `NavigateToPose`
```yaml
@@ -521,10 +879,10 @@ builtin_interfaces/Duration navigation_time
builtin_interfaces/Duration estimated_time_remaining
int16 number_of_recoveries
float32 distance_remaining
```
----
---
### `SmoothPath`
```yaml
@@ -540,10 +898,10 @@ builtin_interfaces/Duration smoothing_duration
bool was_completed
---
#feedback definition
```
----
---
### `Spin`
```yaml
@@ -556,10 +914,10 @@ builtin_interfaces/Duration total_elapsed_time
---
#feedback definition
float32 angular_distance_traveled
```
----
---
### `Wait`
```yaml
@@ -571,7 +929,6 @@ builtin_interfaces/Duration total_elapsed_time
---
#feedback definition
builtin_interfaces/Duration time_left
```
----
---

View File

@@ -1,37 +1,142 @@
# 添加新动作指令Action
1.`unilabos_msgs/action` 中新建实验操作名和参数列表,如 `MyDeviceCmd.action`。一个 Action 定义由三个部分组成分别是目标Goal、结果Result和反馈Feedback之间使用 `---` 分隔:
本指南将引导你完成添加新动作指令的整个流程,包括编写、在线构建和测试。
## 1. 编写新的 Action
### 1.1 创建 Action 文件
`unilabos_msgs/action` 目录中新建实验操作文件,如 `MyDeviceCmd.action`。一个 Action 定义由三个部分组成分别是目标Goal、结果Result和反馈Feedback之间使用 `---` 分隔:
```action
# 目标Goal
# 目标Goal- 定义动作执行所需的参数
string command
float64 timeout
---
# 结果Result
bool success
# 结果Result- 定义动作完成后返回的结果
bool success # 要求必须包含success以便回传执行结果
string return_info # 要求必须包含return_info以便回传执行结果
... # 其他
---
# 反馈Feedback
# 反馈Feedback- 定义动作执行过程中的反馈信息
float64 progress
string status
```
2.`unilabos_msgs/CMakeLists.txt` 中添加新定义的 action
### 1.2 更新 CMakeLists.txt
`unilabos_msgs/CMakeLists.txt` 中的 `add_action_files()` 部分添加新定义的 action
```cmake
add_action_files(
FILES
MyDeviceCmd.action
# 其他已有的 action 文件...
)
```
3. 因为在指令集中新建了指令,因此调试时需要编译,并在终端环境中加载临时路径:
## 2. 在线构建和测试
为了简化开发流程并确保构建环境的一致性,我们使用 GitHub Actions 进行在线构建。
### 2.1 Fork 仓库并创建分支
1. **Fork 仓库**:在 GitHub 上 fork `Uni-Lab-OS` 仓库到你的个人账户
2. **Clone 你的 fork**
```bash
git clone https://github.com/YOUR_USERNAME/Uni-Lab-OS.git
cd Uni-Lab-OS
```
3. **创建功能分支**
```bash
git checkout -b add-my-device-action
```
4. **提交你的更改**
```bash
git add unilabos_msgs/action/MyDeviceCmd.action
git add unilabos_msgs/CMakeLists.txt
git commit -m "Add MyDeviceCmd action for device control"
git push origin add-my-device-action
```
### 2.2 触发在线构建
1. **访问你的 fork 仓库**:在浏览器中打开你的 fork 仓库页面
2. **手动触发构建**
- 点击 "Actions" 标签
- 选择 "Multi-Platform Conda Build" 工作流
- 点击 "Run workflow" 按钮
3. **监控构建状态**
- 构建过程大约需要 5-10 分钟
- 在 Actions 页面可以实时查看构建日志
- 构建完成后,可以下载生成的 conda 包进行测试
### 2.3 下载和测试构建包
1. **下载构建产物**
- 在构建完成的 Action 页面,找到 "Artifacts" 部分
- 下载对应平台的 `conda-package-*` 文件
2. **本地测试安装**
```bash
# 解压下载的构建产物
unzip conda-package-linux-64.zip # 或其他平台
# 安装测试包
mamba install ./ros-humble-unilabos-msgs-*.conda
```
3. **验证 Action 是否正确添加**
```bash
# 检查 action 是否可用
ros2 interface show unilabos_msgs/action/MyDeviceCmd
```
## 3. 提交 Pull Request
测试成功后,向主仓库提交 Pull Request
1. **创建 Pull Request**
- 在你的 fork 仓库页面,点击 "New Pull Request"
- 选择你的功能分支作为源分支
- 填写详细的 PR 描述,包括:
- 添加的 Action 功能说明
- 测试结果
- 相关的设备或用例
2. **等待审核和合并**
- 维护者会审核你的代码
- CI/CD 系统会自动运行完整的测试套件
- 合并后,新的指令集会自动发布到官方 conda 仓库
## 4. 使用新的 Action
如果采用自己构建的action包可以通过以下命令更新安装
```bash
cd unilabos_msgs
colcon build
source ./install/local_setup.sh
cd ..
mamba remove --force ros-humble-unilabos-msgs
mamba config set safety_checks disabled # 如果没有提升版本号会触发md5与网络上md5不一致是正常现象因此通过本指令关闭md5检查
mamba install xxx.conda --offline
```
调试成功后,发起 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
```
**Q: 构建失败怎么办?**
A: 检查 Actions 日志中的错误信息,通常是语法错误或依赖问题。修复后重新推送代码即可自动触发新的构建。
**Q: 如何测试特定平台?**
A: 在手动触发构建时,在平台选择中只填写你需要的平台,如 `linux-64` 或 `win-64`。
**Q: 构建包在哪里下载?**
A: 在 Actions 页面的构建结果中,查找 "Artifacts" 部分,每个平台都有对应的构建包可供下载。

View File

@@ -0,0 +1,147 @@
# 电池装配工站接入PLC
本指南将引导你完成电池装配工站(以 PLC 控制为例)的接入流程,包括新建工站文件、编写驱动与寄存器读写、生成注册表、上传及注意事项。
## 1. 新建工站文件
### 1.1 创建工站文件
`unilabos/devices/workstation/coin_cell_assembly` 目录下新建工站文件,如 `coin_cell_assembly.py`。工站类需继承 `WorkstationBase`,并在构造函数中初始化通信客户端与寄存器映射。
```python
from typing import Optional
# 工站基类
from unilabos.devices.workstation.workstation_base import WorkstationBase
# Modbus 通讯与寄存器 CSV 支持
from unilabos.device_comms.modbus_plc.client import TCPClient, BaseClient
class CoinCellAssemblyWorkstation(WorkstationBase):
def __init__(
self,
station_resource,
address: str = "192.168.1.20",
port: str = "502",
*args,
**kwargs,
):
super().__init__(station_resource=station_resource, *args, **kwargs)
self.station_resource = station_resource # 物料台面Deck
self.success: bool = False
self.allow_data_read: bool = False
self.csv_export_thread = None
self.csv_export_running = False
self.csv_export_file: Optional[str] = None
# 连接 PLC并注册寄存器节点
tcp = TCPClient(addr=address, port=port)
tcp.client.connect()
self.nodes = BaseClient.load_csv(".../PLC_register.csv")
self.client = tcp.register_node_list(self.nodes)
```
## 2. 编写驱动与寄存器读写
### 2.1 寄存器示例
- `COIL_SYS_START_CMD`BOOL地址 8010启动命令脉冲式
- `COIL_SYS_START_STATUS`BOOL地址 8210启动状态
- `REG_DATA_OPEN_CIRCUIT_VOLTAGE`FLOAT32地址 10002开路电压
- `REG_DATA_ASSEMBLY_PRESSURE`INT16地址 10014压制扣电压力
### 2.2 最小驱动示例
```python
from unilabos.device_comms.modbus_plc.modbus import WorderOrder
def start_and_read_metrics(self):
# 1) 下发启动(置 True 再复位 False
self.client.use_node('COIL_SYS_START_CMD').write(True)
self.client.use_node('COIL_SYS_START_CMD').write(False)
# 2) 等待进入启动状态
while True:
status, _ = self.client.use_node('COIL_SYS_START_STATUS').read(1)
if bool(status[0]):
break
# 3) 读取关键数据FLOAT32 需读 2 个寄存器并指定字节序)
voltage, _ = self.client.use_node('REG_DATA_OPEN_CIRCUIT_VOLTAGE').read(
2, word_order=WorderOrder.LITTLE
)
pressure, _ = self.client.use_node('REG_DATA_ASSEMBLY_PRESSURE').read(1)
return {
'open_circuit_voltage': voltage,
'assembly_pressure': pressure,
}
```
> 提示:若需参数下发,可在 PLC 端设置标志寄存器并完成握手复位,避免粘连与竞争。
## 3. 本地生成注册表并校验
完成工站类与驱动后,需要生成(或更新)工站注册表供系统识别。
### 3.1 新增工站设备(或资源)首次生成注册表
首先通过以下命令启动unilab。进入unilab系统状态检查页面
```bash
python unilabos\app\main.py -g celljson.json --ak <user的AK> --sk <user的SK>
```
点击注册表编辑,进入注册表编辑页面
![Layers](image_add_batteryPLC/unilab_sys_status.png)
按照图示步骤填写自动生成注册表信息:
![Layers](image_add_batteryPLC/unilab_registry_process.png)
步骤说明:
1. 选择新增的工站`coin_cell_assembly.py`文件
2. 点击分析按钮,分析`coin_cell_assembly.py`文件
3. 选择`coin_cell_assembly.py`文件中继承`WorkstationBase`
4. 填写新增的工站.py文件与`unilabos`目录的距离。例如,新增的工站文件`coin_cell_assembly.py`路径为`unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`,则此处填写`unilabos.devices.workstation.coin_cell_assembly`
5. 此处填写新定义工站的类的名字(名称可以自拟)
6. 填写新的工站注册表备注信息
7. 生成注册表
以上操作步骤完成则会生成的新的注册表ymal文件如下图
![Layers](image_add_batteryPLC/unilab_new_yaml.png)
### 3.2 添加新生成注册表
`unilabos\registry\devices`目录下新建一个yaml文件此处新建文件命名为`coincellassemblyworkstation_device.yaml`,将上面生成的新的注册表信息粘贴到`coincellassemblyworkstation_device.yaml`文件中。
在终端输入以下命令进行注册表补全操作。
```bash
python unilabos\app\register.py --complete_registry
```
### 3.3 启动并上传注册表
新增设备之后启动unilab需要增加`--upload_registry`参数,来上传注册表信息。
```bash
python unilabos\app\main.py -g celljson.json --ak <user的AK> --sk <user的SK> --upload_registry
```
## 4. 注意事项
- 在新生成的 YAML 中,确认 `module` 指向新工站类,本例中需检查`coincellassemblyworkstation_device.yaml`文件中是否指向了`coin_cell_assembly.py`文件中定义的`CoinCellAssemblyWorkstation`类文件:
```
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
```
- 首次新增设备(或资源)需要在网页端新增注册表信息,`--complete_registry`补全注册表,`--upload_registry`上传注册表信息。
- 如果不是新增设备(或资源),仅对工站驱动的.py文件进行了修改则不需要在网页端新增注册表信息。只需要运行补全注册表信息之后上传注册表即可。

View File

@@ -13,36 +13,36 @@ class MockGripper:
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)
@@ -68,7 +68,7 @@ public class MockGripper
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)
{
@@ -94,107 +94,61 @@ public class MockGripper
C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能使用,但只需一次。
## 注册表文件位置
## 快速开始:使用注册表编辑器(推荐)
Uni-Lab 启动时会自动读取默认注册表路径 `unilabos/registry/devices` 下的所有注册设备。您也可以任意维护自己的注册表路径,只需要在 Uni-Lab 启动时使用 `--registry` 参数将路径添加即可。
推荐使用 Uni-Lab-OS 自带的可视化编辑器,它能自动分析您的设备驱动并生成大部分配置:
`<path-to-registry>/devices` 中新建一个 yaml 文件,即可开始撰写。您可以将多个设备写到同一个 yaml 文件中。
1. 启动 Uni-Lab-OS
2. 在浏览器中打开"注册表编辑器"页面
3. 选择您的 Python 设备驱动文件
4. 点击"分析文件",让系统读取类信息
5. 填写基本信息(设备描述、图标等)
6. 点击"生成注册表",复制生成的内容
7. 保存到 `devices/` 目录下
## 注册表的结构
---
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
my_device: # 设备唯一标识符
class:
module: unilabos.devices.your_module.my_device:MyDevice # Python 类路径
type: python # 驱动类型
```
4. 定义设备的定时发布属性。注意,对于 Python Class 来说PROP 是 class 的 `property`,或满足能被 `getattr(cls, PROP)``cls.get_PROP` 读取到的属性值的对象。
### 注册表文件位置
- 默认路径:`unilabos/registry/devices`
- 自定义路径:启动时使用 `--registry` 参数指定
- 可将多个设备写在同一个 yaml 文件中
### 系统自动生成的内容
系统会自动分析您的 Python 驱动类并生成:
- `status_types`:从 `get_*` 方法自动识别状态属性
- `action_value_mappings`:从类方法自动生成动作映射
- `init_param_schema`:从 `__init__` 方法分析初始化参数
- `schema`:前端显示用的属性类型定义
### 完整结构概览
```yaml
status_types:
PROP: TYPE
```
5. 定义设备支持的动作
添加设备支持的动作及其目标、反馈和结果:
```yaml
action_value_mappings:
set_speed:
type: SendCmd
goal:
command: speed
feedback: {}
result:
success: success
my_device:
class:
module: unilabos.devices.your_module.my_device:MyDevice
type: python
status_types: {} # 自动生成
action_value_mappings: {} # 自动生成
description: '' # 可选:设备描述
icon: '' # 可选:设备图标
init_param_schema: {} # 自动生成
schema: {} # 自动生成
```
在 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
```
详细的注册表编写指南和高级配置,请参考{doc}`yaml 注册表编写指南 <add_yaml>`

View File

@@ -1,95 +1,610 @@
# yaml注册表编写指南
# yaml 注册表编写指南
`注册表的结构`
## 快速开始:使用注册表编辑器
1. 顶层名称:每个设备的注册表以设备名称开头,例如 new_device
2. class 字段:定义设备的模块路径和类型。
3. schema 字段:定义设备的属性模式,包括属性类型、描述和必需字段。
4. action_value_mappings 字段:定义设备支持的动作及其目标、反馈和结果。
推荐使用 UniLabOS 自带的可视化编辑器,它能帮你自动生成大部分配置,省去手写的麻烦
`创建新的注册表教程`
1. 创建文件
在 devices 文件夹中创建一个新的 YAML 文件,例如 new_device.yaml。
### 怎么用编辑器
2. 定义设备名称
在文件中定义设备的顶层名称例如new_device
1. 启动 UniLabOS
2. 在浏览器中打开"注册表编辑器"页面
3. 选择你的 Python 设备驱动文件
4. 点击"分析文件",让系统读取你的类信息
5. 填写一些基本信息(设备描述、图标啥的)
6. 点击"生成注册表",复制生成的内容
7. 把内容保存到 `devices/` 目录下
3. 定义设备的类信息
添加设备的模块路径和类型:
我们为你准备了一个测试驱动用于在界面上尝试注册表生成参见目录test\registry\example_devices.py
```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导航设备通过多个点。
如果你想自己写 yaml 文件,或者想深入了解结构,查阅下方说明。
type: <ActionType> # 动作的类型,表示动作的功能
# 根据动作的功能选择合适的类型:
# SendCmd发送简单命令。
# NavigateThroughPoses导航动作。
# SingleJointPosition设置单一关节的位置。
# Stir搅拌动作。
# HeatChill加热或冷却动作。
## 注册表的基本结构
goal: # 定义动作的目标值映射,表示需要传递给设备的参数。
<goal_key>: <mapped_value> #确定设备需要的输入参数,并将其映射到设备的字段。
yaml 注册表就是设备的配置文件,里面定义了设备怎么用、有什么功能。好消息是系统会自动帮你填大部分内容,你只需要写两个必需的东西:设备名和 class 信息。
feedback: # 定义动作的反馈值映射,表示设备执行动作时返回的实时状态。
<feedback_key>: <mapped_value>
result: # 定义动作的结果值映射,表示动作完成后返回的最终结果。
<result_key>: <mapped_value>
### 各字段用途
| 字段名 | 类型 | 需要手写 | 说明 |
| ----------------- | ------ | -------- | ----------------------------------- |
| 设备标识符 | string | 是 | 设备的唯一名字,比如 `mock_chiller` |
| class | object | 部分 | 设备的核心信息,必须写 |
| description | string | 否 | 设备描述,系统默认给空字符串 |
| handles | array | 否 | 连接关系,默认是空的 |
| icon | string | 否 | 图标路径,默认为空 |
| init_param_schema | object | 否 | 初始化参数,系统自动分析生成 |
| version | string | 否 | 版本号,默认 "1.0.0" |
| category | array | 否 | 设备分类,默认用文件名 |
| config_info | array | 否 | 嵌套配置,默认为空 |
| file_path | string | 否 | 文件路径,系统自动设置 |
| registry_type | string | 否 | 注册表类型,自动设为 "device" |
### class 字段里有啥
class 是核心部分,包含这些内容:
| 字段名 | 类型 | 需要手写 | 说明 |
| --------------------- | ------ | -------- | ---------------------------------- |
| module | string | 是 | Python 类的路径,必须写 |
| type | string | 是 | 驱动类型,一般写 "python" |
| status_types | object | 否 | 状态类型,系统自动分析生成 |
| action_value_mappings | object | 部分 | 动作配置,系统会自动生成一些基础的 |
## 怎么创建新的注册表
### 创建文件
在 devices 文件夹里新建一个 yaml 文件,比如 `new_device.yaml`
### 完整结构是什么样的
```yaml
new_device: # 设备名,要唯一
class: # 核心配置
action_value_mappings: # 动作配置(后面会详细说)
action_name:
# 具体的动作设置
module: unilabos.devices.your_module.new_device:NewDeviceClass # 你的 Python 类
status_types: # 状态类型(系统会自动生成)
status: str
temperature: float
# 其他状态
type: python # 驱动类型,一般就是 python
description: New Device Description # 设备描述
handles: [] # 连接关系,通常是空的
icon: '' # 图标路径
init_param_schema: # 初始化参数(系统会自动生成)
config: # 初始化时需要的参数
properties:
port:
default: DEFAULT_PORT
type: string
required: []
type: object
data: # 前端显示用的数据类型
properties:
status:
type: string
temperature:
type: number
required:
- status
type: object
version: 0.0.1 # 版本号
category:
- device_category # 设备类别
config_info: [] # 嵌套配置,通常为空
```
6. 定义设备的属性模式
添加设备的属性模式,包括属性类型和描述:
```python
schema:
type: object
## action_value_mappings 怎么写
这个部分定义设备能做哪些动作。好消息是系统会自动生成大部分动作,你通常只需要添加一些特殊的自定义动作。
### 系统自动生成哪些动作
系统会帮你生成这些:
1.`auto-` 开头的动作:从你 Python 类的方法自动生成
2. 通用的驱动动作:
- `_execute_driver_command`:同步执行驱动命令(仅本地可用)
- `_execute_driver_command_async`:异步执行驱动命令(仅本地可用)
### 如果要手动定义动作
如果你需要自定义一些特殊动作,需要这些字段:
| 字段名 | 需要手写 | 说明 |
| ---------------- | -------- | -------------------------------- |
| type | 是 | 动作类型,必须指定 |
| goal | 是 | 输入参数怎么映射 |
| feedback | 否 | 实时反馈,通常为空 |
| result | 是 | 结果怎么返回 |
| goal_default | 部分 | 参数默认值ROS 动作会自动生成 |
| schema | 部分 | 前端表单配置ROS 动作会自动生成 |
| handles | 否 | 连接关系,默认为空 |
| placeholder_keys | 否 | 特殊输入字段配置 |
### 动作类型有哪些
| 类型 | 什么时候用 | 系统会自动生成什么 |
| ---------------------- | -------------------- | ---------------------- |
| UniLabJsonCommand | 自定义同步 JSON 命令 | 啥都不生成 |
| UniLabJsonCommandAsync | 自定义异步 JSON 命令 | 啥都不生成 |
| ROS 动作类型 | 标准 ROS 动作 | goal_default 和 schema |
常用的 ROS 动作类型:
- `SendCmd`:发送简单命令
- `NavigateThroughPoses`:导航动作
- `SingleJointPosition`:单关节位置控制
- `Stir`:搅拌动作
- `HeatChill``HeatChillStart`:加热冷却动作
### 复杂一点的例子
```yaml
heat_chill_start:
type: HeatChillStart
goal:
purpose: purpose
temp: temp
goal_default: # ROS动作会自动生成你也可以手动覆盖
purpose: ''
temp: 0.0
handles:
output:
- handler_key: labware
label: Labware
data_type: resource
data_source: handle
data_key: liquid
placeholder_keys:
purpose: unilabos_resources
result:
status: status
success: success
# schema 系统会自动生成,不用写
```
### 动作名字怎么起
根据设备用途来起名字:
- 启动停止类:`start``stop``pause``resume`
- 设置参数类:`set_speed``set_temperature``set_timer`
- 移动控制类:`move_to_position``move_through_points`
- 功能操作类:`stir``heat_chill_start``heat_chill_stop`
- 开关控制类:`valve_open_cmd``valve_close_cmd``push_to`
- 命令执行类:`send_nav_task``execute_command_from_outer`
### 常用的动作类型
- `UniLabJsonCommand`:自定义 JSON 命令(不走 ROS
- `UniLabJsonCommandAsync`:异步 JSON 命令(不走 ROS
- `SendCmd`:发送简单命令
- `NavigateThroughPoses`:导航相关
- `SingleJointPosition`:单关节控制
- `Stir`:搅拌
- `HeatChill``HeatChillStart`:加热冷却
- 其他的 ROS 动作类型:看具体的 ROS 服务
### 示例:完整的动作配置
```yaml
heat_chill_start:
type: HeatChillStart
goal:
purpose: purpose
temp: temp
goal_default:
purpose: ''
temp: 0.0
handles:
output:
- handler_key: labware
label: Labware
data_type: resource
data_source: handle
data_key: liquid
placeholder_keys:
purpose: unilabos_resources
result:
status: status
success: success
schema:
description: '启动加热冷却功能'
properties:
goal:
properties:
purpose:
type: string
description: '用途说明'
temp:
type: number
description: '目标温度'
required:
- purpose
- temp
title: HeatChillStart_Goal
type: object
required:
- goal
title: HeatChillStart
type: object
feedback: {}
```
## 系统自动生成的字段
### status_types
系统会扫描你的 Python 类,从状态方法自动生成这部分:
```yaml
status_types:
current_temperature: float # 从 get_current_temperature() 方法来的
is_heating: bool # 从 get_is_heating() 方法来的
status: str # 从 get_status() 方法来的
```
注意几点:
- 系统会找所有 `get_` 开头的方法
- 类型会自动转成 ROS 类型(比如 `str` 变成 `String`
- 如果类型是 `Any``None` 或者不知道的,就默认用 `String`
### init_param_schema
这个完全是系统自动生成的,你不用管:
```yaml
init_param_schema:
config: # 从你类的 __init__ 方法分析出来的
properties:
port:
type: string
default: '/dev/ttyUSB0'
baudrate:
type: integer
default: 9600
required: []
type: object
data: # 根据 status_types 生成的前端用的类型
properties:
current_temperature:
type: number
is_heating:
type: boolean
status:
type: string
description: The status of the device
speed:
type: number
description: The speed of the device
required:
- status
- speed
additionalProperties: false
type: object
```
# 写完yaml注册表后需要添加到哪些其他文件
生成规则很简单:
- `config` 部分:看你类的 `__init__` 方法有什么参数,类型和默认值是啥
- `data` 部分:根据 `status_types` 生成前端显示用的类型定义
### 其他自动填充的字段
```yaml
version: '1.0.0' # 默认版本
category: ['文件名'] # 用你的 yaml 文件名当类别
description: '' # 默认为空,你可以手动改
icon: '' # 默认为空,你可以加图标
handles: [] # 默认空数组
config_info: [] # 默认空数组
file_path: '/path/to/file' # 系统自动填文件路径
registry_type: 'device' # 自动设为设备类型
```
### handles 字段
这个是定义设备连接关系的,类似动作里的 handles 一样:
```yaml
handles: # 大多数时候都是空的,除非设备本身需要连接啥
- handler_key: device_output
label: Device Output
data_type: resource
data_source: value
data_key: default_value
```
### 其他可以配置的字段
```yaml
description: '设备的详细描述' # 写清楚设备是干啥的
icon: 'device_icon.webp' # 设备图标文件名会上传到OSS
version: '0.0.1' # 版本号
category: # 设备分类,前端会用这个分组
- 'heating'
- 'cooling'
- 'temperature_control'
config_info: # 嵌套配置,如果设备包含子设备
- children:
- opentrons_24_tuberack_nest_1point5ml_snapcap_A1
- other_nested_component
```
## 完整的例子
这里是一个比较完整的设备配置示例:
```yaml
my_temperature_controller:
class:
action_value_mappings:
heat_start:
type: HeatChillStart
goal:
target_temp: temp
vessel: vessel
goal_default:
target_temp: 25.0
vessel: ''
handles:
output:
- handler_key: heated_sample
label: Heated Sample
data_type: resource
data_source: handle
data_key: sample
placeholder_keys:
vessel: unilabos_resources
result:
status: status
success: success
schema:
description: '启动加热功能'
properties:
goal:
properties:
target_temp:
type: number
description: '目标温度'
vessel:
type: string
description: '容器标识'
required:
- target_temp
- vessel
title: HeatStart_Goal
type: object
required:
- goal
title: HeatStart
type: object
feedback: {}
stop:
type: UniLabJsonCommand
goal: {}
goal_default: {}
handles: {}
result:
status: status
schema:
description: '停止设备'
properties:
goal:
type: object
title: Stop_Goal
title: Stop
type: object
feedback: {}
module: unilabos.devices.temperature.my_controller:MyTemperatureController
status_types:
current_temperature: float
target_temperature: float
is_heating: bool
is_cooling: bool
status: str
vessel: str
type: python
description: '我的温度控制器设备'
handles: []
icon: 'temperature_controller.webp'
init_param_schema:
config:
properties:
port:
default: '/dev/ttyUSB0'
type: string
baudrate:
default: 9600
type: number
required: []
type: object
data:
properties:
current_temperature:
type: number
target_temperature:
type: number
is_heating:
type: boolean
is_cooling:
type: boolean
status:
type: string
vessel:
type: string
required:
- current_temperature
- target_temperature
- status
type: object
version: '1.0.0'
category:
- 'temperature_control'
- 'heating'
config_info: []
```
## 怎么部署和使用
### 方法一:用编辑器(推荐)
1. 先写好你的 Python 驱动类
2. 用注册表编辑器自动生成 yaml 配置
3. 把生成的文件保存到 `devices/` 目录
4. 重启 UniLabOS 就能用了
### 方法二:手动写(简化版)
1. 创建最简配置:
```yaml
# devices/my_device.yaml
my_device:
class:
module: unilabos.devices.my_module.my_device:MyDevice
type: python
```
2. 启动系统时用 `complete_registry=True` 参数,让系统自动补全
3. 检查一下生成的配置是不是你想要的
### Python 驱动类要怎么写
你的设备类要符合这些要求:
```python
from unilabos.common.device_base import DeviceBase
class MyDevice(DeviceBase):
def __init__(self, config):
"""初始化,参数会自动分析到 init_param_schema.config"""
super().__init__(config)
self.port = config.get('port', '/dev/ttyUSB0')
# 状态方法(会自动生成到 status_types
def get_status(self):
"""返回设备状态"""
return "idle"
def get_temperature(self):
"""返回当前温度"""
return 25.0
# 动作方法(会自动生成 auto- 开头的动作)
async def start_heating(self, temperature: float):
"""开始加热到指定温度"""
pass
def stop(self):
"""停止操作"""
pass
```
### 系统集成
1. 把 yaml 文件放到 `devices/` 目录下
2. 系统启动时会自动扫描并加载设备
3. 系统会自动补全所有缺失的字段
4. 设备马上就能在前端界面中使用
### 高级配置
如果需要特殊设置,可以手动加:
```yaml
my_device:
class:
module: unilabos.devices.my_module.my_device:MyDevice
type: python
action_value_mappings:
# 自定义动作
special_command:
type: UniLabJsonCommand
goal: {}
result: {}
# 可选的自定义配置
description: '我的特殊设备'
icon: 'my_device.webp'
category: ['temperature', 'heating']
```
## 常见问题怎么排查
### 设备加载不了
1. 检查模块路径:确认 `class.module` 路径写对了
2. 确认类能导入:看看你的 Python 驱动类能不能正常导入
3. 检查语法:用 yaml 验证器看看文件格式对不对
4. 查看日志:看 UniLabOS 启动时有没有报错信息
### 自动生成失败了
1. 类分析出问题:确认你的类继承了正确的基类
2. 方法类型不明确:确保状态方法的返回类型写清楚了
3. 导入有问题:检查类能不能被动态导入
4. 没开完整注册:确认启用了 `complete_registry=True`
### 前端显示有问题
1. 重新生成:删掉旧的 yaml 文件,用编辑器重新生成
2. 清除缓存:清除浏览器缓存,重新加载页面
3. 检查字段:确认必需的字段(比如 `schema`)都有
4. 验证数据:检查 `goal_default``schema` 的数据类型是不是一致
### 动作执行出错
1. 方法名不对:确认动作方法名符合规范(比如 `execute_<action_name>`
2. 参数映射错误:检查 `goal` 字段的参数映射是否正确
3. 返回格式不对:确认方法返回值格式符合 `result` 映射
4. 没异常处理:在驱动类里加上异常处理
## 最佳实践
### 开发流程
1. **优先使用编辑器**:除非有特殊需求,否则优先使用注册表编辑器
2. **最小化配置**:手动配置时只定义必要字段,让系统自动生成其他内容
3. **增量开发**:先创建基本配置,后续根据需要添加特殊动作
### 代码规范
1. **方法命名**:状态方法使用 `get_` 前缀,动作方法使用动词开头
2. **类型注解**:为方法参数和返回值添加类型注解
3. **文档字符串**:为类和方法添加详细的文档字符串
4. **异常处理**:实现完善的错误处理和日志记录
### 配置管理
1. **版本控制**:所有 yaml 文件纳入版本控制
2. **命名一致性**:设备 ID、文件名、类名保持一致的命名风格
3. **定期更新**:定期运行完整注册以更新自动生成的字段
4. **备份配置**:在修改前备份重要的手动配置
### 测试验证
1. **本地测试**:在本地环境充分测试后再部署
2. **渐进部署**:先部署到测试环境,验证无误后再上生产环境
3. **监控日志**:密切监控设备加载和运行日志
4. **回滚准备**:准备快速回滚机制,以应对紧急情况
### 性能优化
1. **按需加载**:只加载实际使用的设备类型
2. **缓存利用**:充分利用系统的注册表缓存机制
3. **资源管理**:合理管理设备连接和资源占用
4. **监控指标**:设置关键性能指标的监控和告警

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -0,0 +1,409 @@
# 物料教程Resource
本教程面向 Uni-Lab-OS 的开发者讲解“物料”的核心概念、3种物料格式UniLab、PyLabRobot、奔耀Bioyond及其相互转换方法并说明4种 children 结构表现形式及使用场景。
---
## 1. 物料是什么
- **物料Resource**指实验工作站中的实体对象包括设备device、操作甲板 deck、试剂、实验耗材也包括设备上承载的具体物料或者包含的容器如container/plate/well/瓶/孔/片等)。
- **物料基本信息**(以 UniLab list格式为例
```jsonc
{
"id": "plate", // 某一类物料的唯一名称
"name": "50ml瓶装试剂托盘", // 在云端显示的名称
"sample_id": null, // 同类物料的不同样品
"children": [
"50ml试剂瓶" // 表示托盘上有一个 50ml 试剂瓶
],
"parent": "deck", // 此物料放置在 deck 上
"type": "plate", // 物料类型
"class": "plate", // 物料对应的注册/类名
"position": {
"x": 0, // 初始放置位置
"y": 0,
"z": 0
},
"config": { // 固有配置(尺寸、旋转等)
"size_x": 400.0,
"size_y": 400.0,
"size_z": 400.0,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {
"bottle_number": 1 // 动态数据(可变化)
}
}
```
## 2. 3种物料格式概览(UniLab、PyLabRobot、奔耀Bioyond)
### 2.1 UniLab 物料格式(云端/项目内通用)
- 结构特征:顶层通常是 `nodes` 列表;每个节点是扁平字典,`children` 是子节点 `id` 列表;`parent` 为父节点 `id``null`
- 用途:
- 云端数据存储、前端可视化、与图结构算法互操作
- 在上传/下载/部署配置时作为标准交换格式
示例片段UniLab 物料格式):
```jsonc
{
"nodes": [
{
"id": "a",
"name": "name_a",
"sample_id": 1,
"type": "deck",
"class": "deck",
"parent": null,
"children": ["b1"],
"position": {"x": 0, "y": 0, "z": 0},
"config": {},
"data": {}
},
{
"id": "b1",
"name": "name_b1",
"sample_id": 1,
"type": "plate",
"class": "plate",
"parent": "a1",
"children": [],
"position": {"x": 0, "y": 0, "z": 0},
"config": {},
"data": {}
}
]
}
```
### 2.2 PyLabRobotPLR物料格式实验流程运行时
- 结构特征:严格的层级树,`children` 为“子资源字典列表”(每个子节点本身是完整对象)。
- 用途:
- 实验流程执行与调度PLR 运行时期望的资源对象格式
- 通过 `Resource.deserialize/serialize``load_all_state/serialize_all_state` 与对象交互
示例片段PRL 物料格式)::
```json
{
"name": "deck",
"type": "Deck",
"category": "deck",
"location": {"x": 0, "y": 0, "z": 0, "type": "Coordinate"},
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"},
"parent_name": null,
"children": [
{
"name": "plate_1",
"type": "Plate",
"category": "plate_96",
"location": {"x": 100, "y": 0, "z": 0, "type": "Coordinate"},
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"},
"parent_name": "deck",
"children": [
{
"name": "A1",
"type": "Well",
"category": "well",
"location": {"x": 0, "y": 0, "z": 0, "type": "Coordinate"},
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"},
"parent_name": "plate_1",
"children": []
}
]
}
]
}
```
### 2.3 奔耀 Bioyond 物料格式(第三方来源)
一般是厂商自己定义的json格式和字段信息需要提取和对应。以下为示例说明。
- 结构特征:顶层 `data` 列表,每项包含 `typeName``code``barCode``name``quantity``unit``locations`(仓位 `whName``x/y/z`)、`detail`(细粒度内容,如瓶内液体或孔位物料)。
- 用途:
- 第三方 WMS/设备的物料清单输入
- 需要自定义映射表将 `typeName` → PLR 类名,对 `locations`/`detail` 进行落位/赋值
示例片段奔耀Bioyond 物料格式):
```json
{
"data": [
{
"id": "3a1b5c10-d4f3-01ac-1e64-5b4be2add4b1",
"typeName": "液",
"code": "0006-00014",
"barCode": "",
"name": "EMC",
"quantity": 50,
"lockQuantity": 2.057,
"unit": "瓶",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a19da43-57b5-5e75-552f-8dbd0ad1075f",
"whid": "3a19da43-57b4-a2a8-3f52-91dbbeb836db",
"whName": "配液站内试剂仓库",
"code": "0003-0003",
"x": 1,
"y": 3,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"code": "0006-00014-01",
"name": "EMC-瓶-1",
"x": 1,
"y": 3,
"z": 1,
"quantity": 500.0
}
]
}
],
"code": 1,
"message": "",
"timestamp": 0
}
```
### 2.4 3种物料格式关键字段对应(UniLab、PyLabRobot、奔耀Bioyond)
| 含义 | UniLab | PyLabRobot (PLR) | 奔耀 Bioyond |
| - | - | - | - |
| 节点唯一名 | `id` | `name` | `name` |
| 父节点引用 | `parent` | `parent_name` | `locations` 坐标(无直接父名,需映射坐标下的物料) |
| 子节点集合 | `children`id 列表或对象列表,视结构而定) | `children`(对象列表) | `detail`(明细,非严格树结构,需要自定义映射) |
| 类型(抽象类别) | `type`device/container/plate/deck/…) | `category`plate/well/…),以及类名 `type` | `typeName`(厂商自定义,如“液”、“加样头(大)”) |
| 运行/业务数据 | `data` | 通过 `serialize_all_state()`/`load_all_state()` 管理的状态 | `quantity``lockQuantity` 等业务数值 |
| 固有配置 | `config`size_x/size_y/size_z/model/ordering… | 资源字典中的同名键(反序列化时按构造签名取用) | 厂商自定义字段(需映射入 PLR/UniLab 的 `config``data` |
| 空间位置 | `position`x/y/z | `location`Coordinate + `rotation`Rotation | `locations`whName、x/y/z不含旋转 |
| 条码/标识 | `config.barcode`(可选) | 常放在配置键中(如 `barcode` | `barCode` |
| 数量单位 | 无固定键,通常在 `data` | 无固定键,通常在配置或状态中 | `unit` |
| 物料编码 | 通常在 `config``data` 自定义 | 通常在配置中自定义 | `code` |
说明:
- Bioyond 不提供显式的树形父子关系,通常通过 `locations` 将物料落位到某仓位/坐标。用 `detail` 表示子级明细。
---
## 3. children 的四种结构表示
- **list扁平列表**:每个节点是扁平字典,`children` 为子节点 `id` 数组。示例UniLab `nodes` 中的单个节点。
```json
{
"nodes": [
{ "id": "root", "parent": null, "children": ["child1"] },
{ "id": "child1", "parent": "root", "children": [] }
]
}
```
- **dict嵌套字典**:节点的 `children``{ child_id: child_node_dict }` 字典。
```json
{
"id": "root",
"parent": null,
"children": {
"child1": { "id": "child1", "parent": "root", "children": {} }
}
}
```
- **tree树形列表**:顶层是 `[root_node, ...]`,每个 `node.children` 是“子节点对象列表”(而非 id 列表)。
```json
[
{
"id": "root",
"parent": null,
"children": [
{ "id": "child1", "parent": "root", "children": [] }
]
}
]
```
- **nestdict顶层嵌套字典**:顶层是 `{root_id: root_node, ...}`,或者根节点自身带 `children: {id: node}` 形态。
```json
{
"root": {
"id": "root",
"parent": null,
"children": {
"child1": { "id": "child1", "parent": "root", "children": {} }
}
}
}
```
这些结构之间可使用 `graphio.py` 中的工具函数互转(见下一节)。
---
## 4. 转换函数及调用
核心代码文件:`unilabos/resources/graphio.py`
### 4.1 结构互转list/dict/tree/nestdict
代码引用:
```217:239:unilabos/resources/graphio.py
def dict_to_tree(nodes: dict, devices_only: bool = False) -> list[dict]:
# ... 由扁平 dictid->node生成树children 为对象列表)
```
```241:267:unilabos/resources/graphio.py
def dict_to_nested_dict(nodes: dict, devices_only: bool = False) -> dict:
# ... 由扁平 dict 生成嵌套字典children 为 {id:node}
```
```270:273:unilabos/resources/graphio.py
def list_to_nested_dict(nodes: list[dict]) -> dict:
# ... 由扁平列表children 为 id 列表)转嵌套字典
```
```275:286:unilabos/resources/graphio.py
def tree_to_list(tree: list[dict]) -> list[dict]:
# ... 由树形列表转回扁平列表children 还原为 id 列表)
```
```289:337:unilabos/resources/graphio.py
def nested_dict_to_list(nested_dict: dict) -> list[dict]:
# ... 由嵌套字典转回扁平列表
```
常见路径:
- UniLab 扁平列表 → 树:`dict_to_tree({r["id"]: r for r in resources})`
- 树 → UniLab 扁平列表:`tree_to_list(resources_tree)`
- 扁平列表 ↔ 嵌套字典:`list_to_nested_dict` / `nested_dict_to_list`
### 4.2 UniLab ↔ PyLabRobotPLR
高层封装:
```339:368:unilabos/resources/graphio.py
def convert_resources_to_type(resources_list: list[dict], resource_type: Union[type, list[type]], *, plr_model: bool = False):
# UniLab -> (NestedDict or PLR)
```
```371:395:unilabos/resources/graphio.py
def convert_resources_from_type(resources_list, resource_type: Union[type, list[type]], *, is_plr: bool = False):
# (NestedDict or PLR) -> UniLab 扁平列表
```
底层转换:
```398:441:unilabos/resources/graphio.py
def resource_ulab_to_plr(resource: dict, plr_model=False) -> "ResourcePLR":
# UniLab 单节点(树根) -> PLR Resource 对象
```
```443:481:unilabos/resources/graphio.py
def resource_plr_to_ulab(resource_plr: "ResourcePLR", parent_name: str = None, with_children=True):
# PLR Resource -> UniLab 单节点(dict)
```
示例:
```python
from unilabos.resources.graphio import convert_resources_to_type, convert_resources_from_type
from pylabrobot.resources.resource import Resource as ResourcePLR
# UniLab 扁平列表 -> PLR 根资源对象
plr_root = convert_resources_to_type(resources_list=ulab_list, resource_type=ResourcePLR)
# PLR 资源对象 -> UniLab 扁平列表(用于保存/上传)
ulab_flat = convert_resources_from_type(resources_list=plr_root, resource_type=ResourcePLR)
```
可选项:
- `plr_model=True`:保留 `model` 字段(默认会移除)。
- `with_children=False``resource_plr_to_ulab` 仅转换当前节点。
### 4.3 奔耀Bioyond→ PLR及进一步到 UniLab
转换入口:
```483:527:unilabos/resources/graphio.py
def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: dict = {}, deck: Any = None) -> list[dict]:
# Bioyond 列表 -> PLR 资源列表,并可根据 deck.warehouses 将资源落位
```
使用示例:
```python
import json
from unilabos.resources.graphio import resource_bioyond_to_plr, convert_resources_from_type
from pylabrobot.resources.resource import Resource as ResourcePLR
resp = json.load(open("unilabos/devices/workstation/bioyond_cell/bioyond_test_yibin.json", encoding="utf-8"))
materials = resp["data"]
# 将第三方类型name映射到 PLR 资源类名(需根据现场定义)
type_mapping = {
"液": "RegularContainer",
"加样头(大)": "RegularContainer"
}
plr_list = resource_bioyond_to_plr(materials, type_mapping=type_mapping, deck=None)
# 如需上传云端UniLab 扁平格式):
ulab_flat = convert_resources_from_type(plr_list, [ResourcePLR])
```
说明:
- `type_mapping` 必须由开发者根据设备/物料种类人工维护。
- 如传入 `deck`,且 `deck.warehouses` 命名与 `whName` 对应可将物料安放到仓库坐标x/y/z
---
## 5. 何时使用哪种格式
- **云端/持久化**:使用 UniLab 物料格式(扁平 `nodes` 列表children 为 id 列表)。便于版本化、可视化与网络传输。
- **实验工作流执行**:使用 PyLabRobotPLR格式。PLR 运行时依赖严格的树形资源结构与对象 API。
- **第三方设备/系统Bioyond输入**:保持来源格式不变,使用 `resource_bioyond_to_plr` + 人工 `type_mapping` 将其转换为 PLR必要时再转 UniLab
---
## 6. 常见问题与注意事项
- **children 形态不一致**:不同函数期望不同 children 形态,注意在进入转换前先用“结构互转”工具函数标准化形态。
- **devices_only**`dict_to_tree/dict_to_nested_dict` 支持仅保留 `type == device` 的节点。
- **模型/类型字段**PLR 对象序列化参数有所差异,`resource_ulab_to_plr` 内部会根据构造签名移除不兼容字段(如 `category`)。
- **驱动初始化**`initialize_resource(s)` 支持从注册表/类路径创建 PLR/UniLab 资源或列表。
参考代码:
```530:577:unilabos/resources/graphio.py
def initialize_resource(resource_config: dict, resource_type: Any = None) -> Union[list[dict], ResourcePLR]:
# 从注册类/模块反射创建资源,或将 UniLab 字典包装为列表
```
```580:597:unilabos/resources/graphio.py
def initialize_resources(resources_config) -> list[dict]:
# 批量初始化
```

View File

@@ -0,0 +1,378 @@
# 工作站基础架构设计文档
## 1. 整体架构图
```mermaid
graph TB
subgraph "工作站基础架构"
WB[WorkstationBase]
WB --> |继承| RPN[ROS2WorkstationNode]
WB --> |组合| WCB[WorkstationCommunicationBase]
WB --> |组合| MMB[MaterialManagementBase]
WB --> |组合| WHS[WorkstationHTTPService]
end
subgraph "通信层实现"
WCB --> |实现| PLC[PLCCommunication]
WCB --> |实现| SER[SerialCommunication]
WCB --> |实现| ETH[EthernetCommunication]
end
subgraph "物料管理实现"
MMB --> |实现| PLR[PyLabRobotMaterialManager]
MMB --> |实现| BIO[BioyondMaterialManager]
MMB --> |实现| SIM[SimpleMaterialManager]
end
subgraph "HTTP服务"
WHS --> |处理| LIMS[LIMS协议报送]
WHS --> |处理| MAT[物料变更报送]
WHS --> |处理| ERR[错误处理报送]
end
subgraph "具体工作站实现"
WB --> |继承| WS1[PLCWorkstation]
WB --> |继承| WS2[ReportingWorkstation]
WB --> |继承| WS3[HybridWorkstation]
end
subgraph "外部系统"
EXT1[PLC设备] --> |通信| PLC
EXT2[外部工作站] --> |HTTP报送| WHS
EXT3[LIMS系统] --> |HTTP报送| WHS
EXT4[Bioyond物料系统] --> |查询| BIO
end
```
## 2. 类关系图
```mermaid
classDiagram
class WorkstationBase {
<<abstract>>
+device_id: str
+communication: WorkstationCommunicationBase
+material_management: MaterialManagementBase
+http_service: WorkstationHTTPService
+workflow_status: WorkflowStatus
+supported_workflows: Dict
+_create_communication_module()*
+_create_material_management_module()*
+_register_supported_workflows()*
+process_step_finish_report()
+process_sample_finish_report()
+process_order_finish_report()
+process_material_change_report()
+handle_external_error()
+start_workflow()
+stop_workflow()
+get_workflow_status()
+get_device_status()
}
class ROS2WorkstationNode {
+sub_devices: Dict
+protocol_names: List
+execute_single_action()
+create_ros_action_server()
+initialize_device()
}
class WorkstationCommunicationBase {
<<abstract>>
+config: CommunicationConfig
+is_connected: bool
+connect()
+disconnect()
+start_workflow()*
+stop_workflow()*
+get_device_status()*
+write_register()
+read_register()
}
class MaterialManagementBase {
<<abstract>>
+device_id: str
+deck_config: Dict
+resource_tracker: DeviceNodeResourceTracker
+plr_deck: Deck
+find_materials_by_type()
+update_material_location()
+convert_to_unilab_format()
+_create_resource_by_type()*
}
class WorkstationHTTPService {
+workstation_instance: WorkstationBase
+host: str
+port: int
+start()
+stop()
+_handle_step_finish_report()
+_handle_material_change_report()
}
class PLCWorkstation {
+plc_config: Dict
+modbus_client: ModbusTCPClient
+_create_communication_module()
+_create_material_management_module()
+_register_supported_workflows()
}
class ReportingWorkstation {
+report_handlers: Dict
+_create_communication_module()
+_create_material_management_module()
+_register_supported_workflows()
}
WorkstationBase --|> ROS2WorkstationNode
WorkstationBase *-- WorkstationCommunicationBase
WorkstationBase *-- MaterialManagementBase
WorkstationBase *-- WorkstationHTTPService
PLCWorkstation --|> WorkstationBase
ReportingWorkstation --|> WorkstationBase
WorkstationCommunicationBase <|-- PLCCommunication
WorkstationCommunicationBase <|-- DummyCommunication
MaterialManagementBase <|-- PyLabRobotMaterialManager
MaterialManagementBase <|-- SimpleMaterialManager
```
## 3. 工作站启动时序图
```mermaid
sequenceDiagram
participant APP as Application
participant WS as WorkstationBase
participant COMM as CommunicationModule
participant MAT as MaterialManager
participant HTTP as HTTPService
participant ROS as ROS2WorkstationNode
APP->>WS: 创建工作站实例
WS->>ROS: 初始化ROS2WorkstationNode
ROS->>ROS: 初始化子设备
ROS->>ROS: 设置硬件接口代理
WS->>COMM: _create_communication_module()
COMM->>COMM: 初始化通信配置
COMM->>COMM: 建立PLC/串口连接
COMM-->>WS: 返回通信模块实例
WS->>MAT: _create_material_management_module()
MAT->>MAT: 创建PyLabRobot Deck
MAT->>MAT: 初始化物料资源
MAT->>MAT: 注册到ResourceTracker
MAT-->>WS: 返回物料管理实例
WS->>WS: _register_supported_workflows()
WS->>WS: _create_workstation_services()
WS->>HTTP: _start_http_service()
HTTP->>HTTP: 创建HTTP服务器
HTTP->>HTTP: 启动监听线程
HTTP-->>WS: HTTP服务启动完成
WS-->>APP: 工作站初始化完成
```
## 4. 工作流执行时序图
```mermaid
sequenceDiagram
participant EXT as ExternalSystem
participant WS as WorkstationBase
participant COMM as CommunicationModule
participant MAT as MaterialManager
participant ROS as ROS2WorkstationNode
participant DEV as SubDevice
EXT->>WS: start_workflow(type, params)
WS->>WS: 验证工作流类型
WS->>COMM: start_workflow(type, params)
COMM->>COMM: 发送启动命令到PLC
COMM-->>WS: 启动成功
WS->>WS: 更新workflow_status = RUNNING
loop 工作流步骤执行
WS->>ROS: execute_single_action(device_id, action, params)
ROS->>DEV: 发送ROS Action请求
DEV->>DEV: 执行设备动作
DEV-->>ROS: 返回执行结果
ROS-->>WS: 返回动作结果
WS->>MAT: update_material_location(material_id, location)
MAT->>MAT: 更新PyLabRobot资源状态
MAT-->>WS: 更新完成
end
WS->>COMM: get_workflow_status()
COMM->>COMM: 查询PLC状态寄存器
COMM-->>WS: 返回状态信息
WS->>WS: 更新workflow_status = COMPLETED
WS-->>EXT: 工作流执行完成
```
## 5. HTTP报送处理时序图
```mermaid
sequenceDiagram
participant EXT as ExternalWorkstation
participant HTTP as HTTPService
participant WS as WorkstationBase
participant MAT as MaterialManager
participant DB as DataStorage
EXT->>HTTP: POST /report/step_finish
HTTP->>HTTP: 解析请求数据
HTTP->>HTTP: 验证LIMS协议字段
HTTP->>WS: process_step_finish_report(request)
WS->>WS: 增加接收计数
WS->>WS: 记录步骤完成事件
WS->>MAT: 更新相关物料状态
MAT->>MAT: 更新PyLabRobot资源
MAT-->>WS: 更新完成
WS->>DB: 保存报送记录
DB-->>WS: 保存完成
WS-->>HTTP: 返回处理结果
HTTP->>HTTP: 构造HTTP响应
HTTP-->>EXT: 200 OK + acknowledgment_id
Note over EXT,DB: 类似处理sample_finish, order_finish, material_change等报送
```
## 6. 错误处理时序图
```mermaid
sequenceDiagram
participant DEV as Device
participant WS as WorkstationBase
participant COMM as CommunicationModule
participant HTTP as HTTPService
participant EXT as ExternalSystem
DEV->>WS: 设备错误事件
WS->>WS: handle_external_error(error_data)
WS->>WS: 记录错误历史
alt 关键错误
WS->>COMM: emergency_stop()
COMM->>COMM: 发送紧急停止命令
WS->>WS: 更新workflow_status = ERROR
else 普通错误
WS->>WS: 标记动作失败
WS->>WS: 触发重试逻辑
end
WS->>HTTP: 记录错误报送
HTTP->>EXT: 主动通知错误状态
WS-->>DEV: 错误处理完成
```
## 7. 典型工作站实现示例
### 7.1 PLC工作站实现
```python
class PLCWorkstation(WorkstationBase):
def _create_communication_module(self):
return PLCCommunication(self.communication_config)
def _create_material_management_module(self):
return PyLabRobotMaterialManager(
self.device_id,
self.deck_config,
self.resource_tracker
)
def _register_supported_workflows(self):
self.supported_workflows = {
"battery_assembly": WorkflowInfo(...),
"quality_check": WorkflowInfo(...)
}
```
### 7.2 报送接收工作站实现
```python
class ReportingWorkstation(WorkstationBase):
def _create_communication_module(self):
return DummyCommunication(self.communication_config)
def _create_material_management_module(self):
return SimpleMaterialManager(
self.device_id,
self.deck_config,
self.resource_tracker
)
def _register_supported_workflows(self):
self.supported_workflows = {
"data_collection": WorkflowInfo(...),
"report_processing": WorkflowInfo(...)
}
```
## 8. 核心接口说明
### 8.1 必须实现的抽象方法
- `_create_communication_module()`: 创建通信模块
- `_create_material_management_module()`: 创建物料管理模块
- `_register_supported_workflows()`: 注册支持的工作流
### 8.2 可重写的报送处理方法
- `process_step_finish_report()`: 步骤完成处理
- `process_sample_finish_report()`: 样本完成处理
- `process_order_finish_report()`: 订单完成处理
- `process_material_change_report()`: 物料变更处理
- `handle_external_error()`: 错误处理
### 8.3 工作流控制接口
- `start_workflow()`: 启动工作流
- `stop_workflow()`: 停止工作流
- `get_workflow_status()`: 获取状态
## 9. 配置参数说明
```python
workstation_config = {
"communication_config": {
"protocol": "modbus_tcp",
"host": "192.168.1.100",
"port": 502
},
"deck_config": {
"size_x": 1000.0,
"size_y": 1000.0,
"size_z": 500.0
},
"http_service_config": {
"enabled": True,
"host": "127.0.0.1",
"port": 8081
},
"communication_interfaces": {
"logical_device_1": CommunicationInterface(...)
}
}
```
这个架构设计支持:
1. **灵活的通信方式**: 通过CommunicationBase支持PLC、串口、以太网等
2. **多样的物料管理**: 支持PyLabRobot、Bioyond、简单物料系统
3. **统一的HTTP报送**: 基于LIMS协议的标准化报送接口
4. **完整的工作流控制**: 支持动态和静态工作流
5. **强大的错误处理**: 多层次的错误处理和恢复机制

View File

@@ -33,6 +33,8 @@ developer_guide/add_device
developer_guide/add_action
developer_guide/actions
developer_guide/add_protocol
developer_guide/add_batteryPLC
developer_guide/materials_tutorial.md
```
## 接口文档

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -1,24 +1,43 @@
# **Uni-Lab 安装**
请先 `git clone` 本仓库,随后按照以下步骤安装项目:
## 快速开始
`Uni-Lab` 建议您采用 `mamba` 管理环境。若需从头建立 `Uni-Lab` 的运行依赖环境,请执行
1. **配置 Conda 环境**
Uni-Lab-OS 建议使用 `mamba` 管理环境。创建新的环境:
```shell
mamba env create -f unilabos-<YOUR_OS>.yaml
mamba activate unilab
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
```
其中 `YOUR_OS` 是您的操作系统,可选值 `win64`, `linux-64`, `osx-64`, `osx-arm64`
若需将依赖安装进当前环境,请执行
2. **安装开发版 Uni-Lab-OS**
```shell
conda env update --file unilabos-<YOUR_OS>.yml
# 配置好conda环境后克隆仓库
git clone https://github.com/dptech-corp/Uni-Lab-OS.git -b dev
cd Uni-Lab-OS
# 安装 Uni-Lab-OS
pip install -e .
```
随后,可在本仓库安装 `unilabos` 的开发版:
3. **安装开发版 ros-humble-unilabos-msgs**
**卸载老版本:**
```shell
pip install .
conda activate unilab
conda remove --force ros-humble-unilabos-msgs
```
有时相同的安装包版本会由于dev构建得到的md5不一样触发安全检查可输入 `config set safety_checks disabled` 来关闭安全检查。
**安装新版本:**
访问 https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/multi-platform-build.yml 选择最新的构建,下载对应平台的压缩包(仅解压一次,得到.conda文件使用如下指令
```shell
conda activate base
conda install ros-humble-unilabos-msgs-<version>-<platform>.conda --offline -n <环境名>
```
4. **启动 Uni-Lab 系统**
请参见{doc}`启动样例 <../boot_examples/index>`或{doc}`启动指南 <launch>`了解详细的启动方法。

View File

@@ -1,4 +1,4 @@
# Uni-Lab 启动
# Uni-Lab 启动指南
安装完毕后,可以通过 `unilab` 命令行启动:
@@ -8,70 +8,315 @@ 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.
Physical setup graph file path.
-c CONTROLLERS, --controllers CONTROLLERS
Controllers config file.
Controllers config file path.
--registry_path REGISTRY_PATH
Path to the registry
Path to the registry directory
--working_dir WORKING_DIR
Path to the working directory
--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
Bridges to connect to. Now support 'websocket' and 'fastapi'.
--is_slave Run the backend as slave node (without host privileges).
--slave_no_host Skip waiting for host service in slave mode
--upload_registry Upload registry information when starting unilab
--use_remote_resource Use remote resources when starting unilab
--config CONFIG Configuration file path, supports .py format Python config files
--port PORT Port for web service information page
--disable_browser Disable opening information page on startup
--2d_vis Enable 2D visualization when starting pylabrobot instance
--visual {rviz,web,disable}
Choose visualization tool: rviz, web, or disable
--ak AK Access key for laboratory requests
--sk SK Secret key for laboratory requests
--addr ADDR Laboratory backend address
--skip_env_check Skip environment dependency check on startup
--complete_registry Complete registry information
```
## 启动流程详解
Uni-Lab 的启动过程分为以下几个阶段:
### 1. 参数解析阶段
- 解析命令行参数
- 处理参数格式转换(支持 dash 和 underscore 格式)
### 2. 环境检查阶段 (可选)
- 默认进行环境依赖检查并自动安装必需包
- 使用 `--skip_env_check` 可跳过此步骤
### 3. 配置文件处理阶段
您可以直接跟随 unilabos 的提示进行,无需查阅本节
- **工作目录设置**
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
- 否则使用 `当前目录/unilabos_data` 作为工作目录
- 可通过 `--working_dir` 指定自定义工作目录
- **配置文件查找顺序**
1. 使用 `--config` 参数指定的配置文件
2. 在工作目录中查找 `local_config.py`
3. 首次使用时会引导创建配置文件
### 4. 服务器地址配置
支持多种后端环境:
- `--addr test`:测试环境 (`https://uni-lab.test.bohrium.com/api/v1`)
- `--addr uat`UAT 环境 (`https://uni-lab.uat.bohrium.com/api/v1`)
- `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`)
- 自定义地址:直接指定完整 URL
### 5. 认证配置
- **必需参数**`--ak``--sk` 必须同时提供
- 命令行参数优先于配置文件中的设置
- 未提供认证信息会导致启动失败并提示注册实验室
### 6. 设备图谱加载
支持两种方式:
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
- **远程资源**:使用 `--use_remote_resource` 从云端获取
### 7. 注册表构建
- 构建设备和资源注册表
- 支持自定义注册表路径 (`--registry_path`)
- 可选择补全注册表信息 (`--complete_registry`)
### 8. 设备验证和注册
- 验证设备连接和端点配置
- 自动注册设备到云端服务
### 9. 通信桥接配置
- **WebSocket**:实时通信和任务下发
- **FastAPI**HTTP API 服务和物料更新
### 10. 可视化和服务启动
- 可选启动可视化工具 (`--visual`)
- 启动 Web 信息服务 (默认端口 8002)
- 启动后端通信服务
## 使用配置文件
Uni-Lab支持使用Python格式的配置文件进行系统设置。通过 `--config` 参数指定配置文件路径:
Uni-Lab 支持使用 Python 格式的配置文件进行系统设置。通过 `--config` 参数指定配置文件路径:
```bash
# 使用配置文件启动
unilab --config path/to/your/config.py
```
配置文件包含MQTT、HTTP、ROS等系统设置。有关配置文件的详细信息,请参阅[配置指南](configuration.md)。
配置文件包含实验室和 WebSocket 连接等设置。有关配置文件的详细信息,请参阅[配置指南](configuration.md)。
## 初始化信息来源
启动 Uni-Lab 时,可以选用两种方式之一配置实验室设备、耗材、通信、控制逻辑
启动 Uni-Lab 时,可以选用两种方式之一配置实验室设备:
### 1. 组态&拓扑图
使用 `-g` 时,组态&拓扑图应包含实验室所有信息,详见{ref}`graph`。目前支持 graphml 和 node-link json 两种格式。格式可参照 `tests/experiments` 下的启动文件。
使用 `-g` 时,组态&拓扑图应包含实验室所有信息,详见{ref}`graph`。目前支持 GraphML 和 node-link JSON 两种格式。格式可参照 `tests/experiments` 下的启动文件。
### 2. 分别指定设备、耗材、控制逻辑
### 2. 分别指定控制逻辑
分别使用 `-d, -r, -c` 依次传入设备组态配置、耗材列表、控制逻辑。
使用 `-c` 传入控制逻辑配置
可参照 `devices.json``resources.json`
不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry` 加入 `<your-registry-path>/{devices,device_comms,resources}`
不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry_path` 加入 `<your-registry-path>/{devices,device_comms,resources}`
## 通信中间件 `--backend`
目前 Uni-Lab 支持 ros2 作为通信中间件
目前 Uni-Lab 支持以下通信中间件
- **ros** (默认):基于 ROS2 的通信
- **simple**:简化通信模式
- **automancer**Automancer 兼容模式
## 端云桥接 `--app_bridges`
目前 Uni-Lab 提供 FastAPI (http), MQTT 两种端云通信方式。其中默认 MQTT 负责端对云状态同步和云对端任务下发FastAPI 负责端对云物料更新。
目前 Uni-Lab 提供 WebSocket、FastAPI (http) 两种端云通信方式
- **WebSocket**:负责实时通信和任务下发
- **FastAPI**:负责端对云物料更新和 HTTP API
## 分布式组网
启动 Uni-Lab 时,加入 `--without_host` 将作为从站,不加将作为主站,主站 (host) 持有物料修改权以及对云端的通信。局域网内分别启动的 Uni-Lab 主站/从站将自动组网,互相能访问所有设备状态、传感器信息并发送指令。
启动 Uni-Lab 时,加入 `--is_slave` 将作为从站,不加将作为主站
- **主站 (host)**:持有物料修改权以及对云端的通信
- **从站 (slave)**:无主机权限,可选择跳过等待主机服务 (`--slave_no_host`)
局域网内分别启动的 Uni-Lab 主站/从站将自动组网,互相能访问所有设备状态、传感器信息并发送指令。
## 可视化选项
### 2D 可视化
使用 `--2d_vis` 在 PyLabRobot 实例启动时同时启动 2D 可视化。
### 3D 可视化
通过 `--visual` 参数选择:
- **rviz**:使用 RViz 进行 3D 可视化
- **web**:使用 Web 界面进行可视化
- **disable** (默认):禁用可视化
## 实验室管理
### 首次使用
如果是首次使用,系统会:
1. 提示前往 https://uni-lab.bohrium.com 注册实验室
2. 引导创建配置文件
3. 设置工作目录
### 认证设置
- `--ak`:实验室访问密钥
- `--sk`:实验室私钥
- 两者必须同时提供才能正常启动
## 完整启动示例
以下是一些常用的启动命令示例:
```bash
# 使用配置文件和组态图启动
unilab -g path/to/graph.json
# 使用组态图启动,上传注册表
unilab --ak your_ak --sk your_sk -g path/to/graph.json --upload_registry
# 使用配置文件和分离的设备/资源文件启动
unilab -d devices.json -r resources.json
# 使用远程资源启动
unilab --ak your_ak --sk your_sk --use_remote_resource
# 更新注册表
unilab --ak your_ak --sk your_sk --complete_registry
# 启动从站模式
unilab --ak your_ak --sk your_sk --is_slave
# 启用可视化
unilab --ak your_ak --sk your_sk --visual web --2d_vis
# 指定本地信息网页服务端口和禁用自动跳出浏览器
unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
```
## 常见问题
### 1. 认证失败
如果提示 "后续运行必须拥有一个实验室",请确保:
- 已在 https://uni-lab.bohrium.com 注册实验室
- 正确设置了 `--ak``--sk` 参数
- 配置文件中包含正确的认证信息
### 2. 配置文件问题
如果配置文件加载失败:
- 确保配置文件是 `.py` 格式
- 检查配置文件语法是否正确
- 首次使用可让系统自动创建示例配置文件
### 3. 网络连接问题
如果无法连接到服务器:
- 检查网络连接
- 确认服务器地址是否正确
- 尝试使用不同的环境地址test、uat、local
### 4. 设备图谱问题
如果设备加载失败:
- 检查图谱文件格式是否正确
- 验证设备连接和端点配置
- 确保注册表路径正确
## 页面操作
### 1. 启动成功
当您启动成功后,可以看到物料列表,节点模版和组态图如图展示
![material.png](image/material.png)
### 2. 根据需求创建设备和物料
我们可以做一个简单的案例
* 在容器1中加入水
* 通过传输泵将容器1中的水转移到容器2中
#### 2.1 添加所需的设备和物料
仪器设备work_station中的workstation 数量x1
仪器设备virtual_device中的virtual_transfer_pump 数量x1
物料耗材container中的container 数量x2
#### 2.2 将设备和物料根据父子关系进行关联
当我们添加设备时,仪器耗材模块的物料列表也会实时更新
我们需要将设备和物料拖拽到workstation中并在画布上将它们连接起来就像真实的设备操作一样
![links.png](image/links.png)
### 3. 创建工作流
进入工作流模块 → 点击"我创建的" → 新建工作流
![new.png](image/new.png)
#### 3.1 新增工作流节点
我们可以进入指定工作流,在空白处右键
* 选择Laboratory→host_node中的creat_resource
* 选择Laboratory→workstation中的PumpTransferProtocol
![creatworkfollow.gif](image/creatworkfollow.gif)
#### 3.2 配置节点参数
根据案例,工作流包含两个步骤:
1. 使用creat_resource在容器中创建水
2. 通过泵传输协议将水传输到另一个容器
我们点击creat_resource卡片上的编辑按钮来配置参数⭐
class_name container
device_id workstation
liquid_input_slot 0或-1均可
liquid_type : water
liquid_volume 根据需求填写即可默认单位ml这里举例50
parent workstation
res_id containe
关联设备名称(原unilabos_device_id) 这里就填写host_node
**配置完成后点击底部保存按钮**
我们点击PumpTransferProtocol卡片上的编辑按钮来配置参数⭐
event transfer_liquid
from_vessel water
to_vessel container1
volume 根据需求填写即可默认单位ml这里举例50
关联设备名称(原unilabos_device_id) 这里就填写workstation
**配置完成后点击底部保存按钮**
#### 3.3 运行工作流
1. 连接两个节点卡片
2. 点击底部保存按钮
3. 点击运行按钮执行工作流
![linksandrun.png](image/linksandrun.png)
### 运行监控
* 运行状态和消息实时显示在底部控制台
* 如有报错,可点击查看详细信息
### 结果验证
工作流完成后,返回仪器耗材模块:
* 点击 container1卡片查看详情
* 确认其中包含参数指定的水和容量

View File

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

View File

@@ -1,22 +0,0 @@
<?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

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

View File

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

190
scripts/create_readme.py Normal file
View File

@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
Create Distribution Package README
===================================
Generate README.txt for conda-pack distribution packages.
Usage:
python create_readme.py <platform> <branch> <output_file>
Arguments:
platform: Platform identifier (win-64, linux-64, osx-64, osx-arm64)
branch: Git branch name
output_file: Output file path (e.g., dist-package/README.txt)
Example:
python create_readme.py win-64 dev dist-package/README.txt
"""
import argparse
import sys
from datetime import datetime, timezone
from pathlib import Path
def get_readme_content(platform: str, branch: str) -> str:
"""
Generate README content for the specified platform.
Args:
platform: Platform identifier
branch: Git branch name
Returns:
str: README content
"""
# Get current UTC time
build_date = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
# Determine platform-specific content
is_windows = platform == "win-64"
if is_windows:
archive_ext = "zip"
install_script = "install_unilab.bat"
platform_instructions = """Windows:
1. Extract the downloaded ZIP file
2. Double-click install_unilab.bat (or run in cmd)
3. Follow the prompts"""
else:
archive_ext = "tar.gz"
install_script = "install_unilab.sh"
platform_name = {"linux-64": "linux-64", "osx-64": "osx-64", "osx-arm64": "osx-arm64"}.get(platform, platform)
platform_instructions = f"""macOS/Linux:
1. Download and extract unilab-pack-{platform_name}.tar.gz
2. Run: bash install_unilab.sh
3. Follow the prompts
Alternative (if downloaded from GitHub Actions):
1. Extract the artifact ZIP file
2. Extract unilab-pack-{platform_name}.tar.gz inside
3. Run: bash install_unilab.sh"""
# Generate README content
readme = f"""UniLabOS Conda-Pack Environment
================================
This package contains a pre-built UniLabOS environment.
Installation Instructions:
--------------------------
{platform_instructions}
The installation script will:
- Automatically find your conda installation
- Extract the environment to conda's envs/unilab directory
- Run conda-unpack to finalize setup
After installation:
conda activate unilab
python verify_installation.py
Verification:
-------------
The verify_installation.py script will check:
- Python version (3.11.11)
- ROS2 rclpy installation
- UniLabOS installation and dependencies
If all checks pass, you're ready to use UniLabOS!
Package Contents:
-----------------
- {install_script} (automatic installation script)
- unilab-env-{platform}.tar.gz (packed conda environment)
- verify_installation.py (environment verification tool)
- README.txt (this file)
Build Information:
------------------
Branch: {branch}
Platform: {platform}
Python: 3.11.11
Date: {build_date}
Troubleshooting:
----------------
If installation fails:
1. Ensure conda or mamba is installed
Check: conda --version
2. Verify you have sufficient disk space
Required: ~5-10 GB after extraction
3. Check installation permissions
You need write access to conda's envs directory
4. For detailed logs, run the install script from terminal
For more help:
- Documentation: docs/user_guide/installation.md
- Quick Start: QUICK_START_CONDA_PACK.md
- Issues: https://github.com/dptech-corp/Uni-Lab-OS/issues
License:
--------
UniLabOS is licensed under GPL-3.0-only.
See LICENSE file for details.
Repository: https://github.com/dptech-corp/Uni-Lab-OS
"""
return readme
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate README.txt for conda-pack distribution",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python create_readme.py win-64 dev dist-package/README.txt
python create_readme.py linux-64 main dist-package/README.txt
""",
)
parser.add_argument("platform", choices=["win-64", "linux-64", "osx-64", "osx-arm64"], help="Platform identifier")
parser.add_argument("branch", help="Git branch name")
parser.add_argument("output_file", help="Output file path")
args = parser.parse_args()
try:
# Generate README content
readme_content = get_readme_content(args.platform, args.branch)
# Create output directory if needed
output_path = Path(args.output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Write README file
with open(output_path, "w", encoding="utf-8") as f:
f.write(readme_content)
print(f"✓ README.txt created: {output_path}")
print(f" Platform: {args.platform}")
print(f" Branch: {args.branch}")
return 0
except Exception as e:
print(f"Error creating README: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""
Create ZIP Archive with ZIP64 Support
======================================
This script creates a ZIP archive with ZIP64 support for large files (>2GB).
It's used in the conda-pack build workflow to package the distribution.
PowerShell's Compress-Archive has a 2GB limitation, so we use Python's zipfile
module with allowZip64=True to handle large conda-packed environments.
Usage:
python create_zip_archive.py <source_dir> <output_zip> [--compression-level LEVEL]
Arguments:
source_dir: Directory to compress
output_zip: Output ZIP file path
--compression-level: Compression level (0-9, default: 6)
Example:
python create_zip_archive.py dist-package unilab-pack-win-64.zip
"""
import argparse
import os
import sys
import zipfile
from pathlib import Path
def create_zip_archive(source_dir: str, output_zip: str, compression_level: int = 6) -> bool:
"""
Create a ZIP archive with ZIP64 support.
Args:
source_dir: Directory to compress
output_zip: Output ZIP file path
compression_level: Compression level (0-9)
Returns:
bool: True if successful
"""
try:
source_path = Path(source_dir)
output_path = Path(output_zip)
# Validate source directory
if not source_path.exists():
print(f"Error: Source directory does not exist: {source_dir}", file=sys.stderr)
return False
if not source_path.is_dir():
print(f"Error: Source path is not a directory: {source_dir}", file=sys.stderr)
return False
# Remove existing output file if present
if output_path.exists():
print(f"Removing existing archive: {output_path}")
output_path.unlink()
# Create ZIP archive
print("=" * 70)
print(f"Creating ZIP archive with ZIP64 support")
print(f" Source: {source_path.absolute()}")
print(f" Output: {output_path.absolute()}")
print(f" Compression: Level {compression_level}")
print("=" * 70)
total_size = 0
file_count = 0
with zipfile.ZipFile(
output_path, "w", zipfile.ZIP_DEFLATED, allowZip64=True, compresslevel=compression_level
) as zipf:
# Walk through source directory
for root, dirs, files in os.walk(source_dir):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, source_dir)
file_size = os.path.getsize(file_path)
# Add file to archive
zipf.write(file_path, arcname)
# Display progress
total_size += file_size
file_count += 1
print(f" [{file_count:3d}] Adding: {arcname:50s} {file_size:>15,} bytes")
# Get final archive size
archive_size = output_path.stat().st_size
compression_ratio = (1 - archive_size / total_size) * 100 if total_size > 0 else 0
# Display summary
print("=" * 70)
print("Archive created successfully!")
print(f" Files added: {file_count}")
print(f" Total size (uncompressed): {total_size:>15,} bytes ({total_size / (1024**3):.2f} GB)")
print(f" Archive size (compressed): {archive_size:>15,} bytes ({archive_size / (1024**3):.2f} GB)")
print(f" Compression ratio: {compression_ratio:.1f}%")
print("=" * 70)
return True
except Exception as e:
print(f"Error creating ZIP archive: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
return False
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Create ZIP archive with ZIP64 support for large files",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python create_zip_archive.py dist-package unilab-pack-win-64.zip
python create_zip_archive.py dist-package unilab-pack-win-64.zip --compression-level 9
""",
)
parser.add_argument("source_dir", help="Directory to compress")
parser.add_argument("output_zip", help="Output ZIP file path")
parser.add_argument(
"--compression-level",
type=int,
default=6,
choices=range(0, 10),
metavar="LEVEL",
help="Compression level (0=no compression, 9=maximum compression, default=6)",
)
args = parser.parse_args()
# Create archive
success = create_zip_archive(args.source_dir, args.output_zip, args.compression_level)
# Exit with appropriate code
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()

View File

@@ -10,11 +10,25 @@ REM Get the directory where this script is located
set "SCRIPT_DIR=%~dp0"
cd /d "%SCRIPT_DIR%"
REM Find conda installation using 'where conda'
REM Find conda installation
echo Searching for conda installation...
REM Method 1: Try to get conda base using 'conda info --base'
set "CONDA_BASE="
for /f "tokens=*" %%i in ('conda info --base 2^>nul') do (
set "CONDA_BASE=%%i"
)
if not "%CONDA_BASE%"=="" (
echo Found conda at: %CONDA_BASE% (via conda info)
goto :conda_found
)
REM Method 2: Use 'where conda' and parse the path
echo Trying alternative method...
for /f "tokens=*" %%i in ('where conda 2^>nul') do (
set "CONDA_PATH=%%i"
goto :found_conda
goto :parse_conda_path
)
echo ERROR: Could not find conda installation!
@@ -23,20 +37,51 @@ echo.
pause
exit /b 1
:found_conda
REM Extract base directory from conda path
REM Path looks like: C:\Users\10230\miniforge3\Library\bin\conda.bat
REM or: C:\Users\10230\miniforge3\Scripts\conda.exe
for %%i in ("%CONDA_PATH%") do set "CONDA_FILE=%%~nxi"
for %%i in ("%CONDA_PATH%") do set "CONDA_BASE=%%~dpi"
:parse_conda_path
REM Parse conda path to find base directory
REM Common paths:
REM C:\Users\hp\miniforge3\Library\bin\conda.bat
REM C:\Users\hp\miniforge3\Scripts\conda.exe
REM C:\Users\hp\miniforge3\condabin\conda.bat
REM Go up two levels to get base directory
for %%i in ("%CONDA_BASE%..") do set "CONDA_BASE=%%~fi"
if "%CONDA_FILE%"=="conda.bat" (
for %%i in ("%CONDA_BASE%..") do set "CONDA_BASE=%%~fi"
echo Found conda executable at: %CONDA_PATH%
REM Check if path contains \Library\bin\ (typical for conda.bat)
echo %CONDA_PATH% | findstr /C:"\Library\bin\" >nul
if not errorlevel 1 (
REM Path like: C:\Users\hp\miniforge3\Library\bin\conda.bat
REM Need to go up 3 levels: bin -> Library -> miniforge3
for %%i in ("%CONDA_PATH%") do set "CONDA_BASE=%%~dpi"
for %%i in ("%CONDA_BASE%..\..\..") do set "CONDA_BASE=%%~fi"
goto :conda_found
)
echo Found conda at: %CONDA_BASE%
REM Check if path contains \Scripts\ (typical for conda.exe)
echo %CONDA_PATH% | findstr /C:"\Scripts\" >nul
if not errorlevel 1 (
REM Path like: C:\Users\hp\miniforge3\Scripts\conda.exe
REM Need to go up 2 levels: Scripts -> miniforge3
for %%i in ("%CONDA_PATH%") do set "CONDA_BASE=%%~dpi"
for %%i in ("%CONDA_BASE%..\.") do set "CONDA_BASE=%%~fi"
goto :conda_found
)
REM Check if path contains \condabin\ (typical for conda.bat)
echo %CONDA_PATH% | findstr /C:"\condabin\" >nul
if not errorlevel 1 (
REM Path like: C:\Users\hp\miniforge3\condabin\conda.bat
REM Need to go up 2 levels: condabin -> miniforge3
for %%i in ("%CONDA_PATH%") do set "CONDA_BASE=%%~dpi"
for %%i in ("%CONDA_BASE%..\.") do set "CONDA_BASE=%%~fi"
goto :conda_found
)
REM Default: assume it's 2 levels up
for %%i in ("%CONDA_PATH%") do set "CONDA_BASE=%%~dpi"
for %%i in ("%CONDA_BASE%..\.") do set "CONDA_BASE=%%~fi"
:conda_found
echo Found conda base directory: %CONDA_BASE%
echo.
REM Set target environment path
@@ -116,6 +161,28 @@ if errorlevel 1 (
exit /b 1
)
echo.
echo Checking UniLabOS entry point...
REM Check if unilab-script.py exists
set "UNILAB_SCRIPT=%ENV_PATH%\Scripts\unilab-script.py"
if not exist "%UNILAB_SCRIPT%" (
echo WARNING: unilab-script.py not found, creating it...
(
echo # -*- coding: utf-8 -*-
echo import re
echo import sys
echo.
echo from unilabos.app.main import main
echo.
echo if __name__ == '__main__':
echo sys.argv[0] = re.sub^(r'(-script\.pyw?^|\.exe^)?$', '', sys.argv[0]^)
echo sys.exit^(main^(^)^)
) > "%UNILAB_SCRIPT%"
echo Created: %UNILAB_SCRIPT%
) else (
echo Found: %UNILAB_SCRIPT%
)
echo.
echo ================================================
echo Installation completed successfully!

View File

@@ -96,6 +96,30 @@ else
exit 1
fi
echo ""
echo "Checking UniLabOS entry point..."
# Check if unilab script exists in bin directory
UNILAB_SCRIPT="$ENV_PATH/bin/unilab"
if [ ! -f "$UNILAB_SCRIPT" ]; then
echo "WARNING: unilab script not found, creating it..."
cat > "$UNILAB_SCRIPT" << 'EOF'
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import re
import sys
from unilabos.app.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
sys.exit(main())
EOF
chmod +x "$UNILAB_SCRIPT"
echo "Created: $UNILAB_SCRIPT"
else
echo "Found: $UNILAB_SCRIPT"
fi
echo ""
echo "================================================"
echo "Installation completed successfully!"

View File

@@ -34,7 +34,7 @@ dependencies:
- uvicorn
- gradio
- flask
- websocket
- websockets
# Notebook
- ipython
- jupyter
@@ -63,6 +63,9 @@ dependencies:
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments
- uni-lab::ros-humble-unilabos-msgs
- zeep
- jinja2
- pprp
- pip:
- paho-mqtt
- opentrons_shared_data

View File

@@ -34,7 +34,7 @@ dependencies:
- uvicorn
- gradio
- flask
- websocket
- websockets
# Notebook
- ipython
- jupyter
@@ -62,6 +62,9 @@ dependencies:
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments
- uni-lab::ros-humble-unilabos-msgs
- zeep
- jinja2
- pprp
- pip:
- paho-mqtt
- opentrons_shared_data

View File

@@ -35,8 +35,7 @@ dependencies:
- uvicorn
- gradio
- flask
- websocket
- paho-mqtt
- websockets
# Notebook
- ipython
- jupyter
@@ -65,6 +64,9 @@ dependencies:
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments
- uni-lab::ros-humble-unilabos-msgs
- zeep
- jinja2
- pprp
- pip:
- paho-mqtt
- opentrons_shared_data

View File

@@ -34,7 +34,7 @@ dependencies:
- uvicorn
- gradio
- flask
- websocket
- websockets
# Notebook
- ipython
- jupyter
@@ -65,6 +65,9 @@ dependencies:
- uni-lab::ros-humble-unilabos-msgs
# driver
#- crcmod
- zeep
- jinja2
- pprp
- pip:
- paho-mqtt
- opentrons_shared_data

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
UniLabOS Installation Verification Script
=========================================
@@ -15,8 +16,38 @@ Usage:
"""
import sys
import os
# IMPORTANT: Set UTF-8 encoding BEFORE any other imports
# This ensures all subsequent imports (including unilabos) can output UTF-8 characters
if sys.platform == "win32":
# Method 1: Reconfigure stdout/stderr to use UTF-8 with error handling
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, OSError):
pass
# Method 2: Set environment variable for subprocess and console
os.environ["PYTHONIOENCODING"] = "utf-8"
# Method 3: Try to change Windows console code page to UTF-8
try:
import ctypes
# Set console code page to UTF-8 (CP 65001)
ctypes.windll.kernel32.SetConsoleCP(65001)
ctypes.windll.kernel32.SetConsoleOutputCP(65001)
except (ImportError, AttributeError, OSError):
pass
# Now import other modules
import importlib
# Use ASCII-safe symbols that work across all platforms
CHECK_MARK = "[OK]"
CROSS_MARK = "[FAIL]"
def check_package(package_name: str, display_name: str = None) -> bool:
"""
@@ -34,10 +65,10 @@ def check_package(package_name: str, display_name: str = None) -> bool:
try:
importlib.import_module(package_name)
print(f" {display_name}")
print(f" {CHECK_MARK} {display_name}")
return True
except ImportError:
print(f" {display_name}")
print(f" {CROSS_MARK} {display_name}")
return False
@@ -47,10 +78,10 @@ def check_python_version() -> bool:
version_str = f"{version.major}.{version.minor}.{version.micro}"
if version.major == 3 and version.minor >= 11:
print(f" Python {version_str}")
print(f" {CHECK_MARK} Python {version_str}")
return True
else:
print(f" Python {version_str} (requires Python 3.8+)")
print(f" {CROSS_MARK} Python {version_str} (requires Python 3.11+)")
return False
@@ -78,26 +109,23 @@ def main():
# Run environment checker from unilabos
print("Checking UniLabOS and dependencies...")
try:
from unilabos.utils.environment_check import EnvironmentChecker
from unilabos.utils.environment_check import check_environment
print(" UniLabOS installed")
print(f" {CHECK_MARK} UniLabOS installed")
checker = EnvironmentChecker()
env_check_passed = checker.check_all_packages()
# Check environment without auto-install (verification only)
# Set show_details=False to suppress detailed Chinese output that may cause encoding issues
env_check_passed = check_environment(auto_install=False, show_details=False)
if env_check_passed:
print(" All required packages available")
print(f" {CHECK_MARK} All required packages available")
else:
print(f" ✗ Missing {len(checker.missing_packages)} package(s):")
for import_name, _ in checker.missing_packages:
print(f" - {import_name}")
all_passed = False
print(f" {CROSS_MARK} Some optional packages are missing")
except ImportError:
print(" UniLabOS not installed")
print(f" {CROSS_MARK} UniLabOS not installed")
all_passed = False
except Exception as e:
print(f" Environment check failed: {str(e)}")
all_passed = False
print(f" {CROSS_MARK} Environment check failed: {str(e)}")
print()
# Summary
@@ -106,18 +134,18 @@ def main():
print("=" * 60)
if all_passed:
print("\n All checks passed! Your UniLabOS installation is ready.")
print(f"\n{CHECK_MARK} All checks passed! Your UniLabOS installation is ready.")
print("\nNext steps:")
print(" 1. Review the documentation: docs/user_guide/launch.md")
print(" 2. Try the examples: docs/boot_examples/")
print(" 3. Configure your devices: unilabos_data/startup_config.json")
return 0
else:
print("\n Some checks failed. Please review the errors above.")
print(f"\n{CROSS_MARK} Some checks failed. Please review the errors above.")
print("\nTroubleshooting:")
print(" 1. Ensure you're in the correct conda environment: conda activate unilab")
print(" 2. Check the installation documentation: docs/user_guide/installation.md")
print(" 3. Try reinstalling: pip install -e .")
print(" 3. Try reinstalling: pip install .")
return 1

View File

@@ -4,20 +4,20 @@ package_name = 'unilabos'
setup(
name=package_name,
version='0.10.4',
version='0.10.7',
packages=find_packages(),
include_package_data=True,
install_requires=['setuptools'],
zip_safe=True,
maintainer='Junhan Chang',
maintainer_email='changjh@pku.edu.cn',
author="The unilabos developers",
maintainer='Junhan Chang, Xuwznln',
maintainer_email='Junhan Chang <changjh@pku.edu.cn>, Xuwznln <18435084+Xuwznln@users.noreply.github.com>',
description='',
license='GPL v3',
tests_require=['pytest'],
entry_points={
'console_scripts': [
"unilab = unilabos.app.main:main",
"unilab-register = unilabos.app.register:main"
"unilab = unilabos.app.main:main"
],
},
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
{
"nodes": [
{
"id": "dispensing_station_bioyond",
"name": "dispensing_station_bioyond",
"children": [
"Bioyond_Dispensing_Deck"
],
"parent": null,
"type": "device",
"class": "dispensing_station.bioyond",
"config": {
"config": {
"api_key": "DE9BDDA0",
"api_host": "http://192.168.1.200:44388"
},
"deck": {
"data": {
"_resource_child_name": "Bioyond_Dispensing_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerPreparationStation_Deck"
}
},
"station_config": {
"station_type": "dispensing_station",
"enable_dispensing_station": true,
"enable_reaction_station": false,
"station_name": "DispensingStation_001",
"description": "Bioyond配液工作站"
},
"protocol_type": []
},
"data": {}
},
{
"id": "Bioyond_Dispensing_Deck",
"name": "Bioyond_Dispensing_Deck",
"sample_id": null,
"children": [],
"parent": "dispensing_station_bioyond",
"type": "deck",
"class": "BIOYOND_PolymerPreparationStation_Deck",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "BIOYOND_PolymerPreparationStation_Deck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
}
]
}

View File

@@ -0,0 +1,148 @@
{
"nodes": [
{
"id": "laiyu_liquid_station",
"name": "LaiYu液体处理工作站",
"children": [
"module_1_8tubes",
"module_2_96well_deep",
"module_3_beaker",
"module_4_96well_tips"
],
"parent": null,
"type": "device",
"class": "laiyu_liquid",
"position": {
"x": 500,
"y": 200,
"z": 0
},
"config": {
"total_modules": 4,
"total_wells": 201,
"safety_margin": {
"x": 5.0,
"y": 5.0,
"z": 5.0
},
"protocol_type": ["LiquidHandlingProtocol", "PipettingProtocol", "TransferProtocol"]
},
"data": {
"status": "Ready",
"version": "1.0"
}
},
{
"id": "module_1_8tubes",
"name": "8管位置模块",
"children": [],
"parent": "laiyu_liquid_station",
"type": "container",
"class": "opentrons_24_tuberack_nest_1point5ml_snapcap",
"position": {
"x": 100,
"y": 100,
"z": 0
},
"config": {
"module_type": "tube_rack",
"wells_count": 8,
"well_diameter": 29.0,
"well_depth": 117.0,
"well_volume": 77000.0,
"well_shape": "circular",
"layout": "2x4"
},
"data": {
"max_volume": 77000.0,
"current_volume": 0.0,
"wells": ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
}
},
{
"id": "module_2_96well_deep",
"name": "96深孔板",
"children": [],
"parent": "laiyu_liquid_station",
"type": "plate",
"class": "nest_96_wellplate_2ml_deep",
"position": {
"x": 300,
"y": 100,
"z": 0
},
"config": {
"module_type": "96_well_deep_plate",
"wells_count": 96,
"well_diameter": 8.2,
"well_depth": 39.4,
"well_volume": 2080.0,
"well_shape": "circular",
"layout": "8x12"
},
"data": {
"max_volume": 2080.0,
"current_volume": 0.0,
"plate_type": "deep_well"
}
},
{
"id": "module_3_beaker",
"name": "敞口玻璃瓶",
"children": [],
"parent": "laiyu_liquid_station",
"type": "container",
"class": "container",
"position": {
"x": 500,
"y": 100,
"z": 0
},
"config": {
"module_type": "beaker_holder",
"wells_count": 1,
"well_diameter": 85.0,
"well_depth": 120.0,
"well_volume": 500000.0,
"well_shape": "circular",
"supported_containers": ["250ml", "500ml", "1000ml"]
},
"data": {
"max_volume": 500000.0,
"current_volume": 0.0,
"container_type": "beaker",
"wells": ["A1"]
}
},
{
"id": "module_4_96well_tips",
"name": "96吸头架",
"children": [],
"parent": "laiyu_liquid_station",
"type": "container",
"class": "tip_rack",
"position": {
"x": 700,
"y": 100,
"z": 0
},
"config": {
"module_type": "tip_rack",
"wells_count": 96,
"well_diameter": 8.2,
"well_depth": 60.0,
"well_volume": 6000.0,
"well_shape": "circular",
"layout": "8x12",
"tip_type": "standard"
},
"data": {
"max_volume": 6000.0,
"current_volume": 0.0,
"tip_capacity": "1000μL",
"tips_available": 96
}
}
],
"links": []
}

View File

@@ -22,8 +22,8 @@
"axis": "Left",
"channel_num": 8,
"setup": false,
"debug": false,
"simulator": false,
"debug": true,
"simulator": true,
"matrix_id": "71593"
},
"data": {},

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
{
"nodes": [
{
"id": "reaction_station_bioyond",
"name": "reaction_station_bioyond",
"parent": null,
"children": [
"Bioyond_Deck"
],
"type": "device",
"class": "reaction_station.bioyond",
"config": {
"bioyond_config": {
"api_key": "DE9BDDA0",
"api_host": "http://192.168.1.200:44402",
"workflow_mappings": {
"reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1",
"reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6",
"Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6",
"Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47",
"Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046",
"Liquid_feeding(titration)": "3a160824-0665-01ed-285a-51ef817a9046",
"Liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a"
},
"material_type_mappings": {
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
"样品板": "BIOYOND_PolymerStation_6VialCarrier"
}
},
"deck": {
"data": {
"_resource_child_name": "Bioyond_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
}
},
"protocol_type": []
},
"data": {}
},
{
"id": "Bioyond_Deck",
"name": "Bioyond_Deck",
"sample_id": null,
"children": [
],
"parent": "reaction_station_bioyond",
"type": "deck",
"class": "BIOYOND_PolymerReactionStation_Deck",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "BIOYOND_PolymerReactionStation_Deck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
}
]
}

View File

@@ -0,0 +1,69 @@
{
"nodes": [
{
"id": "reaction_station_bioyond",
"name": "reaction_station_bioyond",
"parent": null,
"children": [
"Bioyond_Deck"
],
"type": "device",
"class": "workstation.bioyond",
"config": {
"bioyond_config": {
"api_key": "DE9BDDA0",
"api_host": "http://192.168.1.200:44388",
"workflow_mappings": {
"reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1",
"reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6",
"Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6",
"Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47",
"Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046",
"Liquid_feeding(titration)": "3a160824-0665-01ed-285a-51ef817a9046",
"Liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a"
},
"material_type_mappings": {
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
"样品板": "BIOYOND_PolymerStation_6VialCarrier"
}
},
"deck": {
"data": {
"_resource_child_name": "Bioyond_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
}
},
"protocol_type": []
},
"data": {}
},
{
"id": "Bioyond_Deck",
"name": "Bioyond_Deck",
"sample_id": null,
"children": [
],
"parent": "reaction_station_bioyond",
"type": "deck",
"class": "BIOYOND_PolymerReactionStation_Deck",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "BIOYOND_PolymerReactionStation_Deck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
}
]
}

View File

@@ -0,0 +1,394 @@
{
"nodes": [
{
"id": "liquid_handler",
"name": "liquid_handler",
"parent": null,
"type": "device",
"class": "liquid_handler",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"data": {},
"children": [
"deck"
],
"config": {
"deck": {
"_resource_child_name": "deck",
"_resource_type": "pylabrobot.resources.opentrons.deck:OTDeck",
"name": "deck"
},
"backend": {
"type": "UniLiquidHandlerRvizBackend"
},
"simulator": true,
"total_height": 300
}
},
{
"id": "deck",
"name": "deck",
"sample_id": null,
"children": [
"tip_rack",
"plate_well",
"tube_rack",
"bottle_rack"
],
"parent": "liquid_handler",
"type": "deck",
"class": "TransformXYZDeck",
"position": {
"x": 0,
"y": 0,
"z": 18
},
"config": {
"type": "TransformXYZDeck",
"size_x": 624.3,
"size_y": 565.2,
"size_z": 900,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
},
{
"id": "tip_rack",
"name": "tip_rack",
"sample_id": null,
"children": [
"tip_rack_A1"
],
"parent": "deck",
"type": "tip_rack",
"class": "tiprack_box",
"position": {
"x": 150,
"y": 7,
"z": 103
},
"config": {
"type": "TipRack",
"size_x": 134,
"size_y": 96,
"size_z": 7.0,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "tip_rack",
"model": "tiprack_box",
"ordering": [
"A1"
]
},
"data": {}
},
{
"id": "tip_rack_A1",
"name": "tip_rack_A1",
"sample_id": null,
"children": [],
"parent": "tip_rack",
"type": "container",
"class": "",
"position": {
"x": 11.12,
"y": 75,
"z": -91.54
},
"config": {
"type": "TipSpot",
"size_x": 9,
"size_y": 9,
"size_z": 95,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "tip_spot",
"model": null,
"prototype_tip": {
"type": "Tip",
"total_tip_length": 95,
"has_filter": false,
"maximal_volume": 1000.0,
"fitting_depth": 3.29
}
},
"data": {
"tip": null,
"tip_state": null,
"pending_tip": null
}
},
{
"id": "plate_well",
"name": "plate_well",
"sample_id": null,
"children": [
"plate_well_A1"
],
"parent": "deck",
"type": "plate",
"class": "plate_96",
"position": {
"x": 161,
"y": 116,
"z": 48.5
},
"pose": {
"position_3d": {
"x": 161,
"y": 116,
"z": 48.5
},
"rotation": {
"x": 0,
"y": 0,
"z": 0
}
},
"config": {
"type": "Plate",
"size_x": 127.76,
"size_y": 85.48,
"size_z": 45.5,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": "plate_96",
"ordering": [
"A1"
]
},
"data": {}
},
{
"id": "plate_well_A1",
"name": "plate_well_A1",
"sample_id": null,
"children": [],
"parent": "plate_well",
"type": "device",
"class": "",
"position": {
"x": 10.1,
"y": 70,
"z": 6.1
},
"config": {
"type": "Well",
"size_x": 8.2,
"size_y": 8.2,
"size_z": 38,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "well",
"model": null,
"max_volume": 2000,
"material_z_thickness": null,
"compute_volume_from_height": null,
"compute_height_from_volume": null,
"bottom_type": "unknown",
"cross_section_type": "rectangle"
},
"data": {
"liquids": [["water", 50.0]],
"pending_liquids": [["water", 50.0]],
"liquid_history": []
}
},
{
"id": "tube_rack",
"name": "tube_rack",
"sample_id": null,
"children": [
"tube_rack_A1"
],
"parent": "deck",
"type": "container",
"class": "tube_container",
"position": {
"x": 0,
"y": 127,
"z": 0
},
"config": {
"type": "Plate",
"size_x": 151,
"size_y": 75,
"size_z": 75,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"model": "tube_container",
"ordering": [
"A1"
]
},
"data": {}
},
{
"id": "tube_rack_A1",
"name": "tube_rack_A1",
"sample_id": null,
"children": [],
"parent": "tube_rack",
"type": "device",
"class": "",
"position": {
"x": 6,
"y": 38,
"z": 10
},
"config": {
"type": "Well",
"size_x": 34,
"size_y": 34,
"size_z": 117,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "tube",
"model": null,
"max_volume": 2000,
"material_z_thickness": null,
"compute_volume_from_height": null,
"compute_height_from_volume": null,
"bottom_type": "unknown",
"cross_section_type": "rectangle"
},
"data": {
"liquids": [["water", 50.0]],
"pending_liquids": [["water", 50.0]],
"liquid_history": []
}
}
,
{
"id": "bottle_rack",
"name": "bottle_rack",
"sample_id": null,
"children": [
"bottle_rack_A1"
],
"parent": "deck",
"type": "container",
"class": "bottle_container",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "Plate",
"size_x": 130,
"size_y": 117,
"size_z": 8,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "tube_rack",
"model": "bottle_container",
"ordering": [
"A1"
]
},
"data": {}
},
{
"id": "bottle_rack_A1",
"name": "bottle_rack_A1",
"sample_id": null,
"children": [],
"parent": "bottle_rack",
"type": "device",
"class": "",
"position": {
"x": 25,
"y": 18.5,
"z": 8
},
"config": {
"type": "Well",
"size_x": 80,
"size_y": 80,
"size_z": 117,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "tube",
"model": null,
"max_volume": 2000,
"material_z_thickness": null,
"compute_volume_from_height": null,
"compute_height_from_volume": null,
"bottom_type": "unknown",
"cross_section_type": "rectangle"
},
"data": {
"liquids": [["water", 50.0]],
"pending_liquids": [["water", 50.0]],
"liquid_history": []
}
}
],
"links": []
}

View File

@@ -0,0 +1,588 @@
"""
示例设备类文件,用于测试注册表编辑器
"""
import asyncio
from typing import Dict, Any, Optional, List
class SmartPumpController:
"""
智能泵控制器
支持多种泵送模式,具有高精度流量控制和自动校准功能。
适用于实验室自动化系统中的液体处理任务。
"""
def __init__(self, device_id: str = "smart_pump_01", port: str = "/dev/ttyUSB0"):
"""
初始化智能泵控制器
Args:
device_id: 设备唯一标识符
port: 通信端口
"""
self.device_id = device_id
self.port = port
self.is_connected = False
self.current_flow_rate = 0.0
self.total_volume_pumped = 0.0
self.calibration_factor = 1.0
self.pump_mode = "continuous" # continuous, volume, rate
def connect_device(self, timeout: int = 10) -> bool:
"""
连接到泵设备
Args:
timeout: 连接超时时间(秒)
Returns:
bool: 连接是否成功
"""
# 模拟连接过程
self.is_connected = True
return True
def disconnect_device(self) -> bool:
"""
断开设备连接
Returns:
bool: 断开连接是否成功
"""
self.is_connected = False
self.current_flow_rate = 0.0
return True
def set_flow_rate(self, flow_rate: float, units: str = "ml/min") -> bool:
"""
设置泵流速
Args:
flow_rate: 流速值
units: 流速单位
Returns:
bool: 设置是否成功
"""
if not self.is_connected:
return False
self.current_flow_rate = flow_rate
return True
async def pump_volume_async(self, volume: float, flow_rate: float) -> Dict[str, Any]:
"""
异步泵送指定体积的液体
Args:
volume: 目标体积 (mL)
flow_rate: 泵送流速 (mL/min)
Returns:
Dict: 包含操作结果的字典
"""
if not self.is_connected:
return {"success": False, "error": "设备未连接"}
# 计算泵送时间
pump_time = (volume / flow_rate) * 60 # 转换为秒
self.current_flow_rate = flow_rate
await asyncio.sleep(min(pump_time, 3.0)) # 模拟泵送过程
self.total_volume_pumped += volume
self.current_flow_rate = 0.0
return {
"success": True,
"pumped_volume": volume,
"actual_time": min(pump_time, 3.0),
"total_volume": self.total_volume_pumped,
}
def emergency_stop(self) -> bool:
"""
紧急停止泵
Returns:
bool: 停止是否成功
"""
self.current_flow_rate = 0.0
return True
def perform_calibration(self, reference_volume: float, measured_volume: float) -> bool:
"""
执行泵校准
Args:
reference_volume: 参考体积
measured_volume: 实际测量体积
Returns:
bool: 校准是否成功
"""
if measured_volume > 0:
self.calibration_factor = reference_volume / measured_volume
return True
return False
# 状态查询方法
def get_connection_status(self) -> str:
"""获取连接状态"""
return "connected" if self.is_connected else "disconnected"
def get_current_flow_rate(self) -> float:
"""获取当前流速 (mL/min)"""
return self.current_flow_rate
def get_total_volume(self) -> float:
"""获取累计泵送体积 (mL)"""
return self.total_volume_pumped
def get_calibration_factor(self) -> float:
"""获取校准因子"""
return self.calibration_factor
def get_pump_mode(self) -> str:
"""获取泵送模式"""
return self.pump_mode
def get_device_status(self) -> Dict[str, Any]:
"""获取设备完整状态信息"""
return {
"device_id": self.device_id,
"connected": self.is_connected,
"flow_rate": self.current_flow_rate,
"total_volume": self.total_volume_pumped,
"calibration_factor": self.calibration_factor,
"mode": self.pump_mode,
"running": self.current_flow_rate > 0,
}
class AdvancedTemperatureController:
"""
高级温度控制器
支持PID控制、多点温度监控和程序化温度曲线。
适用于需要精确温度控制的化学反应和材料处理过程。
"""
def __init__(self, controller_id: str = "temp_controller_01"):
"""
初始化温度控制器
Args:
controller_id: 控制器ID
"""
self.controller_id = controller_id
self.current_temperature = 25.0
self.target_temperature = 25.0
self.is_heating = False
self.is_cooling = False
self.pid_enabled = True
self.temperature_history: List[Dict] = []
def set_target_temperature(self, temperature: float, rate: float = 10.0) -> bool:
"""
设置目标温度
Args:
temperature: 目标温度 (°C)
rate: 升温/降温速率 (°C/min)
Returns:
bool: 设置是否成功
"""
self.target_temperature = temperature
return True
async def heat_to_temperature_async(
self, temperature: float, tolerance: float = 0.5, timeout: int = 600
) -> Dict[str, Any]:
"""
异步加热到指定温度
Args:
temperature: 目标温度 (°C)
tolerance: 温度容差 (°C)
timeout: 最大等待时间 (秒)
Returns:
Dict: 操作结果
"""
self.target_temperature = temperature
start_temp = self.current_temperature
if temperature > start_temp:
self.is_heating = True
elif temperature < start_temp:
self.is_cooling = True
# 模拟温度变化过程
steps = min(abs(temperature - start_temp) * 2, 20) # 计算步数
step_time = min(timeout / steps if steps > 0 else 1, 2.0) # 每步最多2秒
for step in range(int(steps)):
progress = (step + 1) / steps
self.current_temperature = start_temp + (temperature - start_temp) * progress
# 记录温度历史
self.temperature_history.append(
{
"timestamp": asyncio.get_event_loop().time(),
"temperature": self.current_temperature,
"target": self.target_temperature,
}
)
await asyncio.sleep(step_time)
# 保持历史记录不超过100条
if len(self.temperature_history) > 100:
self.temperature_history.pop(0)
# 最终设置为目标温度
self.current_temperature = temperature
self.is_heating = False
self.is_cooling = False
return {
"success": True,
"final_temperature": self.current_temperature,
"start_temperature": start_temp,
"time_taken": steps * step_time,
}
def enable_pid_control(self, kp: float = 1.0, ki: float = 0.1, kd: float = 0.05) -> bool:
"""
启用PID控制
Args:
kp: 比例增益
ki: 积分增益
kd: 微分增益
Returns:
bool: 启用是否成功
"""
self.pid_enabled = True
return True
def run_temperature_program(self, program: List[Dict]) -> bool:
"""
运行温度程序
Args:
program: 温度程序列表,每个元素包含温度和持续时间
Returns:
bool: 程序启动是否成功
"""
# 模拟程序启动
return True
# 状态查询方法
def get_current_temperature(self) -> float:
"""获取当前温度 (°C)"""
return round(self.current_temperature, 2)
def get_target_temperature(self) -> float:
"""获取目标温度 (°C)"""
return self.target_temperature
def get_heating_status(self) -> bool:
"""获取加热状态"""
return self.is_heating
def get_cooling_status(self) -> bool:
"""获取制冷状态"""
return self.is_cooling
def get_pid_status(self) -> bool:
"""获取PID控制状态"""
return self.pid_enabled
def get_temperature_history(self) -> List[Dict]:
"""获取温度历史记录"""
return self.temperature_history[-10:] # 返回最近10条记录
def get_controller_status(self) -> Dict[str, Any]:
"""获取控制器完整状态"""
return {
"controller_id": self.controller_id,
"current_temp": self.current_temperature,
"target_temp": self.target_temperature,
"is_heating": self.is_heating,
"is_cooling": self.is_cooling,
"pid_enabled": self.pid_enabled,
"history_count": len(self.temperature_history),
}
class MultiChannelAnalyzer:
"""
多通道分析仪
支持同时监测多个通道的信号,提供实时数据采集和分析功能。
常用于光谱分析、电化学测量等应用场景。
"""
def __init__(self, analyzer_id: str = "analyzer_01", channels: int = 8):
"""
初始化多通道分析仪
Args:
analyzer_id: 分析仪ID
channels: 通道数量
"""
self.analyzer_id = analyzer_id
self.channel_count = channels
self.channel_data = {i: {"value": 0.0, "unit": "V", "enabled": True} for i in range(channels)}
self.is_measuring = False
self.sample_rate = 1000 # Hz
def configure_channel(self, channel: int, enabled: bool = True, unit: str = "V") -> bool:
"""
配置通道
Args:
channel: 通道编号
enabled: 是否启用
unit: 测量单位
Returns:
bool: 配置是否成功
"""
if 0 <= channel < self.channel_count:
self.channel_data[channel]["enabled"] = enabled
self.channel_data[channel]["unit"] = unit
return True
return False
async def start_measurement_async(self, duration: int = 10) -> Dict[str, Any]:
"""
开始异步测量
Args:
duration: 测量持续时间(秒)
Returns:
Dict: 测量结果
"""
self.is_measuring = True
# 模拟数据采集
measurements = []
for second in range(duration):
timestamp = asyncio.get_event_loop().time()
frame_data = {}
for channel in range(self.channel_count):
if self.channel_data[channel]["enabled"]:
# 模拟传感器数据
import random
value = random.uniform(-5.0, 5.0)
frame_data[f"channel_{channel}"] = value
self.channel_data[channel]["value"] = value
measurements.append({"timestamp": timestamp, "data": frame_data})
await asyncio.sleep(1.0) # 每秒采集一次
self.is_measuring = False
return {
"success": True,
"duration": duration,
"samples_count": len(measurements),
"measurements": measurements[-5:], # 只返回最后5个样本
"channels_active": len([ch for ch in self.channel_data.values() if ch["enabled"]]),
}
def stop_measurement(self) -> bool:
"""
停止测量
Returns:
bool: 停止是否成功
"""
self.is_measuring = False
return True
def reset_channels(self) -> bool:
"""
重置所有通道
Returns:
bool: 重置是否成功
"""
for channel in self.channel_data:
self.channel_data[channel]["value"] = 0.0
return True
# 状态查询方法
def get_measurement_status(self) -> bool:
"""获取测量状态"""
return self.is_measuring
def get_channel_count(self) -> int:
"""获取通道数量"""
return self.channel_count
def get_sample_rate(self) -> float:
"""获取采样率 (Hz)"""
return self.sample_rate
def get_channel_values(self) -> Dict[int, float]:
"""获取所有通道的当前值"""
return {ch: data["value"] for ch, data in self.channel_data.items() if data["enabled"]}
def get_enabled_channels(self) -> List[int]:
"""获取已启用的通道列表"""
return [ch for ch, data in self.channel_data.items() if data["enabled"]]
def get_analyzer_status(self) -> Dict[str, Any]:
"""获取分析仪完整状态"""
return {
"analyzer_id": self.analyzer_id,
"channel_count": self.channel_count,
"is_measuring": self.is_measuring,
"sample_rate": self.sample_rate,
"active_channels": len(self.get_enabled_channels()),
"channel_data": self.channel_data,
}
class AutomatedDispenser:
"""
自动分配器
精确控制固体和液体材料的分配,支持多种分配模式和容器管理。
集成称重功能,确保分配精度和重现性。
"""
def __init__(self, dispenser_id: str = "dispenser_01"):
"""
初始化自动分配器
Args:
dispenser_id: 分配器ID
"""
self.dispenser_id = dispenser_id
self.is_ready = True
self.current_position = {"x": 0.0, "y": 0.0, "z": 0.0}
self.dispensed_total = 0.0
self.container_capacity = 1000.0 # mL
self.precision_mode = True
def move_to_position(self, x: float, y: float, z: float) -> bool:
"""
移动到指定位置
Args:
x: X坐标 (mm)
y: Y坐标 (mm)
z: Z坐标 (mm)
Returns:
bool: 移动是否成功
"""
self.current_position = {"x": x, "y": y, "z": z}
return True
async def dispense_liquid_async(self, volume: float, container_id: str, viscosity: str = "low") -> Dict[str, Any]:
"""
异步分配液体
Args:
volume: 分配体积 (mL)
container_id: 容器ID
viscosity: 液体粘度等级
Returns:
Dict: 分配结果
"""
if not self.is_ready:
return {"success": False, "error": "设备未就绪"}
if volume <= 0:
return {"success": False, "error": "体积必须大于0"}
# 模拟分配过程
dispense_time = volume * 0.1 # 每mL需要0.1秒
if viscosity == "high":
dispense_time *= 2 # 高粘度液体需要更长时间
await asyncio.sleep(min(dispense_time, 5.0)) # 最多等待5秒
self.dispensed_total += volume
return {
"success": True,
"dispensed_volume": volume,
"container_id": container_id,
"actual_time": min(dispense_time, 5.0),
"total_dispensed": self.dispensed_total,
}
def clean_dispenser(self, wash_volume: float = 5.0) -> bool:
"""
清洗分配器
Args:
wash_volume: 清洗液体积 (mL)
Returns:
bool: 清洗是否成功
"""
# 模拟清洗过程
return True
def calibrate_volume(self, target_volume: float) -> bool:
"""
校准分配体积
Args:
target_volume: 校准目标体积 (mL)
Returns:
bool: 校准是否成功
"""
# 模拟校准过程
return True
# 状态查询方法
def get_ready_status(self) -> bool:
"""获取就绪状态"""
return self.is_ready
def get_current_position(self) -> Dict[str, float]:
"""获取当前位置坐标"""
return self.current_position.copy()
def get_dispensed_total(self) -> float:
"""获取累计分配体积 (mL)"""
return self.dispensed_total
def get_container_capacity(self) -> float:
"""获取容器容量 (mL)"""
return self.container_capacity
def get_precision_mode(self) -> bool:
"""获取精密模式状态"""
return self.precision_mode
def get_dispenser_status(self) -> Dict[str, Any]:
"""获取分配器完整状态"""
return {
"dispenser_id": self.dispenser_id,
"ready": self.is_ready,
"position": self.current_position,
"dispensed_total": self.dispensed_total,
"capacity": self.container_capacity,
"precision_mode": self.precision_mode,
}

View File

@@ -0,0 +1,181 @@
[
{
"id": "3a1c62c4-c3d2-b803-b72d-7f1153ffef3b",
"typeName": "试剂瓶",
"code": "0004-00050",
"barCode": "",
"name": "NMP",
"quantity": 287.16699029126215,
"lockQuantity": 285.16699029126215,
"unit": "毫升",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198c-c2d0-efce-0939-69ca5a7dfd39",
"whid": "3a14198c-c2cc-0290-e086-44a428fba248",
"whName": "试剂堆栈",
"code": "0001-0008",
"x": 2,
"y": 4,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1cdefe-0e03-1bc1-1296-dae1905c4108",
"typeName": "试剂瓶",
"code": "0004-00052",
"barCode": "",
"name": "NMP",
"quantity": 386.8990291262136,
"lockQuantity": 45.89902912621359,
"unit": "毫升",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198c-c2d0-f3e7-871a-e470d144296f",
"whid": "3a14198c-c2cc-0290-e086-44a428fba248",
"whName": "试剂堆栈",
"code": "0001-0005",
"x": 2,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1cdefe-0e03-68a4-bcb3-02fc6ba72d1b",
"typeName": "试剂瓶",
"code": "0004-00053",
"barCode": "",
"name": "NMP",
"quantity": 400.0,
"lockQuantity": 0.0,
"unit": "",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198c-c2d0-2070-efc8-44e245f10c6f",
"whid": "3a14198c-c2cc-0290-e086-44a428fba248",
"whName": "试剂堆栈",
"code": "0001-0006",
"x": 2,
"y": 2,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1cdefe-d5e0-d850-5439-4499f20f07fe",
"typeName": "分装板",
"code": "0007-00185",
"barCode": "",
"name": "1010",
"quantity": 1.0,
"lockQuantity": 2.0,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198e-6929-46fe-841e-03dd753f1e4a",
"whid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
"whName": "粉末堆栈",
"code": "0002-0009",
"x": 3,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1cdefe-d5e0-28a4-f5d0-f7e2436c575f",
"detailMaterialId": "3a1cdefe-d5e0-94ae-f770-27847e73ad38",
"code": null,
"name": "90%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 2,
"y": 3,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-3ed6-3607-133df89baf5b",
"detailMaterialId": "3a1cdefe-d5e0-f2fa-66bf-94c565d852fb",
"code": null,
"name": "10%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 1,
"y": 3,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-72b6-e015-be7b93cf09eb",
"detailMaterialId": "3a1cdefe-d5e0-81cf-7dad-2e51cab9ffd6",
"code": null,
"name": "90%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 2,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-81d3-ad30-48134afc9ce7",
"detailMaterialId": "3a1cdefe-d5e0-3fa1-cc72-fda6276ae38d",
"code": null,
"name": "10%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 1,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-dbdf-d966-9a8926fe1e06",
"detailMaterialId": "3a1cdefe-d5e0-c632-c7da-02d385b18628",
"code": null,
"name": "10%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 1,
"y": 2,
"z": 1,
"associateId": null
},
{
"id": "3a1cdefe-d5e0-f099-b260-e3089a2d08c3",
"detailMaterialId": "3a1cdefe-d5e0-561f-73b6-f8501f814dbb",
"code": null,
"name": "90%分装小瓶",
"quantity": "1",
"lockQuantity": "1",
"unit": "个",
"x": 2,
"y": 2,
"z": 1,
"associateId": null
}
]
}
]

View File

@@ -0,0 +1,216 @@
[
{
"id": "3a1cde21-a4f4-4f95-6221-eaafc2ae6a8d",
"typeName": "样品瓶",
"code": "0002-00407",
"barCode": "",
"name": "ODA",
"quantity": 25.0,
"lockQuantity": 2.0,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cde21-a4f4-7887-9258-e8f8ab7c8a7a",
"typeName": "样品板",
"code": "0008-00160",
"barCode": "",
"name": "1010sample",
"quantity": 1.0,
"lockQuantity": 27.69187,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198e-6929-4379-affa-9a2935c17f99",
"whid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
"whName": "粉末堆栈",
"code": "0002-0002",
"x": 1,
"y": 2,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1cde21-a4f4-0339-f2b6-8e680ad7e8c7",
"detailMaterialId": "3a1cde21-a4f4-ab37-f7a2-ecc3bc083e7c",
"code": null,
"name": "MPDA",
"quantity": "10.505",
"lockQuantity": "-0.0174",
"unit": "克",
"x": 2,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cde21-a4f4-a21a-23cf-bb7857b41947",
"detailMaterialId": "3a1cde21-a4f4-99c7-55e7-c80c7320e300",
"code": null,
"name": "ODA",
"quantity": "1.795",
"lockQuantity": "2.0093",
"unit": "克",
"x": 1,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cde21-a4f4-af1b-ba0b-2874836800e9",
"detailMaterialId": "3a1cde21-a4f4-4f95-6221-eaafc2ae6a8d",
"code": null,
"name": "ODA",
"quantity": "25",
"lockQuantity": "2",
"unit": "克",
"x": 1,
"y": 2,
"z": 1,
"associateId": null
}
]
},
{
"id": "3a1cde21-a4f4-99c7-55e7-c80c7320e300",
"typeName": "样品瓶",
"code": "0002-00406",
"barCode": "",
"name": "ODA",
"quantity": 1.795,
"lockQuantity": 2.00927,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cde21-a4f4-ab37-f7a2-ecc3bc083e7c",
"typeName": "样品瓶",
"code": "0002-00408",
"barCode": "",
"name": "MPDA",
"quantity": 10.505,
"lockQuantity": -0.0174,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cdeff-c92a-08f6-c822-732ab734154c",
"typeName": "样品板",
"code": "0008-00161",
"barCode": "",
"name": "1010sample2",
"quantity": 1.0,
"lockQuantity": 3.0,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14198e-6929-31f0-8a22-0f98f72260df",
"whid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
"whName": "粉末堆栈",
"code": "0002-0001",
"x": 1,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1cdeff-c92b-3ace-9623-0bcdef6fa07d",
"detailMaterialId": "3a1cdeff-c92b-d084-2a96-5d62746d9321",
"code": null,
"name": "BTDA1",
"quantity": "0.362",
"lockQuantity": "14.494",
"unit": "克",
"x": 1,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1cdeff-c92b-856e-f481-792b91b6dbde",
"detailMaterialId": "3a1cdeff-c92b-30f2-f907-8f5e2fe0586b",
"code": null,
"name": "BTDA3",
"quantity": "1.935",
"lockQuantity": "13.067",
"unit": "克",
"x": 1,
"y": 2,
"z": 1,
"associateId": null
},
{
"id": "3a1cdeff-c92b-d144-c5e5-ab9d94e21187",
"detailMaterialId": "3a1cdeff-c92b-519f-a70f-0bb71af537a7",
"code": null,
"name": "BTDA2",
"quantity": "1.903",
"lockQuantity": "13.035",
"unit": "克",
"x": 2,
"y": 1,
"z": 1,
"associateId": null
}
]
},
{
"id": "3a1cdeff-c92b-30f2-f907-8f5e2fe0586b",
"typeName": "样品瓶",
"code": "0002-00411",
"barCode": "",
"name": "BTDA3",
"quantity": 1.935,
"lockQuantity": 13.067,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cdeff-c92b-519f-a70f-0bb71af537a7",
"typeName": "样品瓶",
"code": "0002-00410",
"barCode": "",
"name": "BTDA2",
"quantity": 1.903,
"lockQuantity": 13.035,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
},
{
"id": "3a1cdeff-c92b-d084-2a96-5d62746d9321",
"typeName": "样品瓶",
"code": "0002-00409",
"barCode": "",
"name": "BTDA1",
"quantity": 0.362,
"lockQuantity": 14.494,
"unit": "克",
"status": 1,
"isUse": false,
"locations": [],
"detail": []
}
]

View File

@@ -0,0 +1,193 @@
[
{
"id": "3a1c67a9-aed7-b94d-9e24-bfdf10c8baa9",
"typeName": "烧杯",
"code": "0006-00160",
"barCode": "",
"name": "ODA",
"quantity": 120000.00000000000000000000000,
"lockQuantity": 695374.00000000000000000000000,
"unit": "微升",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14aa17-0d49-11d7-a6e1-f236b3e5e5a3",
"whid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
"whName": "堆栈1",
"code": "0001-0001",
"x": 1,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1c67a9-aed9-1ade-5fe1-cc04b24b171c",
"typeName": "烧杯",
"code": "0006-00161",
"barCode": "",
"name": "MPDA",
"quantity": 120000.00000000000000000000000,
"lockQuantity": 681618.00000000000000000000000,
"unit": "",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14aa17-0d49-4bc5-8836-517b75473f5f",
"whid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
"whName": "堆栈1",
"code": "0001-0002",
"x": 1,
"y": 2,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1c67a9-aed9-2864-6783-2cee4e701ba6",
"typeName": "试剂瓶",
"code": "0004-00041",
"barCode": "",
"name": "NMP",
"quantity": 300000.00000000000000000000000,
"lockQuantity": 380000.00000000000000000000000,
"unit": "微升",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14aa3b-9fab-adac-7b9c-e1ee446b51d5",
"whid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
"whName": "站内试剂存放堆栈",
"code": "0003-0001",
"x": 1,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1c67a9-aed9-32c7-5809-3ba1b8db1aa1",
"typeName": "试剂瓶",
"code": "0004-00042",
"barCode": "",
"name": "PGME",
"quantity": 300000.00000000000000000000000,
"lockQuantity": 337892.00000000000000000000000,
"unit": "",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14aa3b-9fab-ca72-febc-b7c304476c78",
"whid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
"whName": "站内试剂存放堆栈",
"code": "0003-0002",
"x": 1,
"y": 2,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1c68c8-0574-d748-725e-97a2e549f085",
"typeName": "样品板",
"code": "0001-00004",
"barCode": "",
"name": "0917",
"quantity": 1.0000000000000000000000000000,
"lockQuantity": 4.0000000000000000000000000000,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14aa17-0d49-f49c-6b66-b27f185a3b32",
"whid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
"whName": "堆栈1",
"code": "0001-0009",
"x": 2,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1c68c8-0574-69a1-9858-4637e0193451",
"detailMaterialId": "3a1c68c8-0574-3630-bd42-bbf3623c5208",
"code": null,
"name": "SIDA",
"quantity": "300000",
"lockQuantity": "4",
"unit": "微升",
"x": 1,
"y": 2,
"z": 1,
"associateId": null
},
{
"id": "3a1c68c8-0574-8d51-3191-a31f5be421e5",
"detailMaterialId": "3a1c68c8-0574-3b20-9ad7-90755f123d53",
"code": null,
"name": "BTDA-2",
"quantity": "300000",
"lockQuantity": "4",
"unit": "微升",
"x": 2,
"y": 2,
"z": 1,
"associateId": null
},
{
"id": "3a1c68c8-0574-da80-735b-53ae2197a360",
"detailMaterialId": "3a1c68c8-0574-f2e4-33b3-90d813567939",
"code": null,
"name": "BTDA-DD",
"quantity": "300000",
"lockQuantity": "28",
"unit": "微升",
"x": 1,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1c68c8-0574-e717-1b1b-99891f875455",
"detailMaterialId": "3a1c68c8-0574-a0ef-e636-68cdc98960e2",
"code": null,
"name": "BTDA-3",
"quantity": "300000",
"lockQuantity": "4",
"unit": "微升",
"x": 2,
"y": 3,
"z": 1,
"associateId": null
},
{
"id": "3a1c68c8-0574-e9bd-6cca-5e261b4f89cb",
"detailMaterialId": "3a1c68c8-0574-9d11-5115-283e8e5510b1",
"code": null,
"name": "BTDA-1",
"quantity": "300000",
"lockQuantity": "4",
"unit": "微升",
"x": 2,
"y": 1,
"z": 1,
"associateId": null
}
]
}
]

View File

@@ -0,0 +1,48 @@
import pytest
from unilabos.resources.bioyond.bottle_carriers import BIOYOND_Electrolyte_6VialCarrier, BIOYOND_Electrolyte_1BottleCarrier
from unilabos.resources.bioyond.bottles import BIOYOND_PolymerStation_Solid_Vial, BIOYOND_PolymerStation_Solution_Beaker, BIOYOND_PolymerStation_Reagent_Bottle
def test_bottle_carrier() -> "BottleCarrier":
print("创建载架...")
# 创建6瓶载架
bottle_carrier = BIOYOND_Electrolyte_6VialCarrier("powder_carrier_01")
print(f"6瓶载架: {bottle_carrier.name}, 位置数: {len(bottle_carrier.sites)}")
# 创建1烧杯载架
beaker_carrier = BIOYOND_Electrolyte_1BottleCarrier("solution_carrier_01")
print(f"1烧杯载架: {beaker_carrier.name}, 位置数: {len(beaker_carrier.sites)}")
# 创建瓶子和烧杯
powder_bottle = BIOYOND_PolymerStation_Solid_Vial("powder_bottle_01")
solution_beaker = BIOYOND_PolymerStation_Solution_Beaker("solution_beaker_01")
reagent_bottle = BIOYOND_PolymerStation_Reagent_Bottle("reagent_bottle_01")
print(f"\n创建的物料:")
print(f"粉末瓶: {powder_bottle.name} - {powder_bottle.diameter}mm x {powder_bottle.height}mm, {powder_bottle.max_volume}μL")
print(f"溶液烧杯: {solution_beaker.name} - {solution_beaker.diameter}mm x {solution_beaker.height}mm, {solution_beaker.max_volume}μL")
print(f"试剂瓶: {reagent_bottle.name} - {reagent_bottle.diameter}mm x {reagent_bottle.height}mm, {reagent_bottle.max_volume}μL")
# 测试放置容器
print(f"\n测试放置容器...")
# 通过载架的索引操作来放置容器
# bottle_carrier[0] = powder_bottle # 放置粉末瓶到第一个位置
print(f"粉末瓶已放置到6瓶载架的位置 0")
# beaker_carrier[0] = solution_beaker # 放置烧杯到第一个位置
print(f"溶液烧杯已放置到1烧杯载架的位置 0")
# 验证放置结果
print(f"\n验证放置结果:")
bottle_at_0 = bottle_carrier[0].resource
beaker_at_0 = beaker_carrier[0].resource
if bottle_at_0:
print(f"位置 0 的瓶子: {bottle_at_0.name}")
if beaker_at_0:
print(f"位置 0 的烧杯: {beaker_at_0.name}")
print("\n载架设置完成!")

View File

@@ -0,0 +1,76 @@
import pytest
import json
import os
from pylabrobot.resources import Resource as ResourcePLR
from unilabos.resources.graphio import resource_bioyond_to_plr
from unilabos.registry.registry import lab_registry
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
lab_registry.setup()
type_mapping = {
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
"样品板": "BIOYOND_PolymerStation_6StockCarrier",
"分装板": "BIOYOND_PolymerStation_6VialCarrier",
"样品瓶": "BIOYOND_PolymerStation_Solid_Stock",
"90%分装小瓶": "BIOYOND_PolymerStation_Solid_Vial",
"10%分装小瓶": "BIOYOND_PolymerStation_Liquid_Vial",
}
type_uuid_mapping = {
"烧杯": "",
"试剂瓶": "",
"样品板": "",
"分装板": "3a14196e-5dfe-6e21-0c79-fe2036d052c4",
"样品瓶": "3a14196a-cf7d-8aea-48d8-b9662c7dba94",
"90%分装小瓶": "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea",
"10%分装小瓶": "3a14196c-76be-2279-4e22-7310d69aed68",
}
@pytest.fixture
def bioyond_materials_reaction() -> list[dict]:
print("加载 BioYond 物料数据...")
print(os.getcwd())
with open("bioyond_materials_reaction.json", "r", encoding="utf-8") as f:
data = json.load(f)
print(f"加载了 {len(data)} 条物料数据")
return data
@pytest.fixture
def bioyond_materials_liquidhandling_1() -> list[dict]:
print("加载 BioYond 物料数据...")
print(os.getcwd())
with open("bioyond_materials_liquidhandling_1.json", "r", encoding="utf-8") as f:
data = json.load(f)
print(f"加载了 {len(data)} 条物料数据")
return data
@pytest.fixture
def bioyond_materials_liquidhandling_2() -> list[dict]:
print("加载 BioYond 物料数据...")
print(os.getcwd())
with open("bioyond_materials_liquidhandling_2.json", "r", encoding="utf-8") as f:
data = json.load(f)
print(f"加载了 {len(data)} 条物料数据")
return data
@pytest.mark.parametrize("materials_fixture", [
"bioyond_materials_reaction",
"bioyond_materials_liquidhandling_1",
])
def test_bioyond_to_plr(materials_fixture, request) -> list[dict]:
materials = request.getfixturevalue(materials_fixture)
deck = BIOYOND_PolymerReactionStation_Deck("test_deck")
output = resource_bioyond_to_plr(materials, type_mapping=type_mapping, deck=deck)
print(deck.summary())
print([resource.serialize() for resource in output])
print([resource.serialize_all_state() for resource in output])
json.dump(deck.serialize(), open("test.json", "w", encoding="utf-8"), indent=4)

View File

@@ -0,0 +1 @@
__version__ = "0.10.7"

View File

@@ -1,38 +1,48 @@
import threading
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
from unilabos.utils import logger
# 根据选择的 backend 启动相应的功能
def start_backend(
backend: str,
devices_config: dict = {},
resources_config: list = [],
resources_edge_config: list = [],
devices_config: ResourceTreeSet,
resources_config: ResourceTreeSet,
resources_edge_config: list[dict] = [],
graph=None,
controllers_config: dict = {},
bridges=[],
without_host: bool = False,
visual: str = "None",
resources_mesh_config: dict = {},
**kwargs
**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':
elif backend == "simple":
# 这里假设 simple_backend 和 automancer_backend 是你定义的其他两个后端
# from simple_backend import main as simple_main
pass
elif backend == 'automancer':
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, resources_edge_config, graph, controllers_config, bridges, visual, resources_mesh_config),
args=(
devices_config,
resources_config,
resources_edge_config,
graph,
controllers_config,
bridges,
visual,
resources_mesh_config,
),
name="backend_thread",
daemon=True,
)

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env python
# coding=utf-8
"""
通信模块
提供WebSocket的统一接口支持通过配置选择通信协议。
包含通信抽象层基类和通信客户端工厂。
"""
from abc import ABC, abstractmethod
from typing import Optional
from unilabos.config.config import BasicConfig
from unilabos.utils import logger
class BaseCommunicationClient(ABC):
"""
通信客户端抽象基类
定义了所有通信客户端WebSocket等需要实现的接口。
"""
def __init__(self):
self.is_disabled = True
self.client_id = ""
@abstractmethod
def start(self) -> None:
"""
启动通信客户端连接
"""
pass
@abstractmethod
def stop(self) -> None:
"""
停止通信客户端连接
"""
pass
@abstractmethod
def publish_device_status(self, device_status: dict, device_id: str, property_name: str) -> None:
"""
发布设备状态信息
Args:
device_status: 设备状态字典
device_id: 设备ID
property_name: 属性名称
"""
pass
@abstractmethod
def publish_job_status(
self, feedback_data: dict, job_id: str, status: str, return_info: Optional[dict] = None
) -> None:
"""
发布作业状态信息
Args:
feedback_data: 反馈数据
job_id: 作业ID
status: 作业状态
return_info: 返回信息
"""
pass
@abstractmethod
def send_ping(self, ping_id: str, timestamp: float) -> None:
"""
发送ping消息
Args:
ping_id: ping ID
timestamp: 时间戳
"""
pass
def setup_pong_subscription(self) -> None:
"""
设置pong消息订阅可选实现
"""
pass
@property
def is_connected(self) -> bool:
"""
检查是否已连接
Returns:
是否已连接
"""
return not self.is_disabled
class CommunicationClientFactory:
"""
通信客户端工厂类
根据配置文件中的通信协议设置创建相应的客户端实例。
"""
_client_cache: Optional[BaseCommunicationClient] = None
@classmethod
def create_client(cls, protocol: Optional[str] = None) -> BaseCommunicationClient:
"""
创建通信客户端实例
Args:
protocol: 指定的协议类型如果为None则使用配置文件中的设置
Returns:
通信客户端实例
Raises:
ValueError: 当协议类型不支持时
"""
if protocol is None:
protocol = BasicConfig.communication_protocol
protocol = protocol.lower()
if protocol == "websocket":
return cls._create_websocket_client()
else:
logger.error(f"[CommunicationFactory] Unsupported protocol: {protocol}")
logger.warning(f"[CommunicationFactory] Falling back to WebSocket")
return cls._create_websocket_client()
@classmethod
def get_client(cls, protocol: Optional[str] = None) -> BaseCommunicationClient:
"""
获取通信客户端实例(单例模式)
Args:
protocol: 指定的协议类型如果为None则使用配置文件中的设置
Returns:
通信客户端实例
"""
if cls._client_cache is None:
cls._client_cache = cls.create_client(protocol)
logger.info(f"[CommunicationFactory] Created {type(cls._client_cache).__name__} client")
return cls._client_cache
@classmethod
def _create_websocket_client(cls) -> BaseCommunicationClient:
"""创建WebSocket客户端"""
try:
from unilabos.app.ws_client import WebSocketClient
return WebSocketClient()
except Exception as e:
logger.error(f"[CommunicationFactory] Failed to create WebSocket client: {str(e)}")
raise
@classmethod
def reset_client(cls):
"""重置客户端缓存(用于测试或重新配置)"""
if cls._client_cache:
try:
cls._client_cache.stop()
except Exception as e:
logger.warning(f"[CommunicationFactory] Error stopping old client: {str(e)}")
cls._client_cache = None
logger.info("[CommunicationFactory] Client cache reset")
@classmethod
def get_supported_protocols(cls) -> list[str]:
"""
获取支持的协议列表
Returns:
支持的协议列表
"""
return ["websocket"]
def get_communication_client(protocol: Optional[str] = None) -> BaseCommunicationClient:
"""
获取通信客户端实例的便捷函数
Args:
protocol: 指定的协议类型如果为None则使用配置文件中的设置
Returns:
通信客户端实例
"""
return CommunicationClientFactory.get_client(protocol)

View File

@@ -6,11 +6,12 @@ import signal
import sys
import threading
import time
from copy import deepcopy
from typing import Dict, Any, List
import networkx as nx
import yaml
from unilabos.resources.graphio import modify_to_backend_format
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet, ResourceDict
# 首先添加项目根目录到路径
current_dir = os.path.dirname(os.path.abspath(__file__))
@@ -18,11 +19,12 @@ unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
if unilabos_dir not in sys.path:
sys.path.append(unilabos_dir)
from unilabos.config.config import load_config, BasicConfig
from unilabos.config.config import load_config, BasicConfig, HTTPConfig
from unilabos.utils.banner_print import print_status, print_unilab_banner
from unilabos.resources.graphio import modify_to_backend_format
def load_config_from_file(config_path, override_labid=None):
def load_config_from_file(config_path):
if config_path is None:
config_path = os.environ.get("UNILABOS_BASICCONFIG_CONFIG_PATH", None)
if config_path:
@@ -31,10 +33,10 @@ def load_config_from_file(config_path, override_labid=None):
elif not config_path.endswith(".py"):
print_status(f"配置文件 {config_path} 不是Python文件必须以.py结尾", "error")
else:
load_config(config_path, override_labid)
load_config(config_path)
else:
print_status(f"启动 UniLab-OS时配置文件参数未正确传入 --config '{config_path}' 尝试本地配置...", "warning")
load_config(config_path, override_labid)
load_config(config_path)
def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
@@ -43,7 +45,7 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
for i, arg in enumerate(sys.argv):
for option_string in option_strings:
if arg.startswith(option_string):
new_arg = arg[:2] + arg[2 : len(option_string)].replace("-", "_") + arg[len(option_string) :]
new_arg = arg[:2] + arg[2:len(option_string)].replace("-", "_") + arg[len(option_string):]
sys.argv[i] = new_arg
break
@@ -51,16 +53,14 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
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("-g", "--graph", help="Physical setup graph file path.")
parser.add_argument("-c", "--controllers", default=None, help="Controllers config file path.")
parser.add_argument(
"--registry_path",
type=str,
default=None,
action="append",
help="Path to the registry",
help="Path to the registry directory",
)
parser.add_argument(
"--working_dir",
@@ -77,72 +77,85 @@ def parse_args():
parser.add_argument(
"--app_bridges",
nargs="+",
default=["mqtt", "fastapi"],
help="Bridges to connect to. Now support 'mqtt' and 'fastapi'.",
default=["websocket", "fastapi"],
help="Bridges to connect to. Now support 'websocket' and 'fastapi'.",
)
parser.add_argument(
"--without_host",
"--is_slave",
action="store_true",
help="Run the backend as slave (without host).",
help="Run the backend as slave node (without host privileges).",
)
parser.add_argument(
"--slave_no_host",
action="store_true",
help="Slave模式下跳过等待host服务",
help="Skip waiting for host service in slave mode",
)
parser.add_argument(
"--upload_registry",
action="store_true",
help="启动unilab时同时报送注册表信息",
help="Upload registry information when starting unilab",
)
parser.add_argument(
"--use_remote_resource",
action="store_true",
help="启动unilab时使用远程资源启动",
help="Use remote resources when starting unilab",
)
parser.add_argument(
"--config",
type=str,
default=None,
help="配置文件路径,支持.py格式的Python配置文件",
help="Configuration file path, supports .py format Python config files",
)
parser.add_argument(
"--port",
type=int,
default=8002,
help="信息页web服务的启动端口",
help="Port for web service information page",
)
parser.add_argument(
"--disable_browser",
action="store_true",
help="是否在启动时关闭信息页",
help="Disable opening information page on startup",
)
parser.add_argument(
"--2d_vis",
action="store_true",
help="是否在pylabrobot实例启动时同时启动可视化",
help="Enable 2D visualization when starting pylabrobot instance",
)
parser.add_argument(
"--visual",
choices=["rviz", "web", "disable"],
default="disable",
help="选择可视化工具: rviz, web",
help="Choose visualization tool: rviz, web, or disable",
)
parser.add_argument(
"--labid",
"--ak",
type=str,
default="",
help="实验室唯一ID也可通过环境变量 UNILABOS_MQCONFIG_LABID 设置或传入--config设置",
help="Access key for laboratory requests",
)
parser.add_argument(
"--sk",
type=str,
default="",
help="Secret key for laboratory requests",
)
parser.add_argument(
"--addr",
type=str,
default="https://uni-lab.bohrium.com/api/v1",
help="Laboratory backend address",
)
parser.add_argument(
"--skip_env_check",
action="store_true",
help="跳过启动时的环境依赖检查",
help="Skip environment dependency check on startup",
)
parser.add_argument(
"--direct_end",
"--complete_registry",
action="store_true",
help="直接结束任务",
default=False,
help="Complete registry information",
)
return parser
@@ -172,7 +185,7 @@ def main():
else:
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
if args_dict.get("working_dir"):
working_dir = args_dict.get("working_dir")
working_dir = args_dict.get("working_dir", "")
if config_path and not os.path.exists(config_path):
config_path = os.path.join(working_dir, "local_config.py")
if not os.path.exists(config_path):
@@ -197,17 +210,36 @@ def main():
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"), config_path
)
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
print_status(f"请在文件夹中配置lab_id放入下载的CA.crt、lab.crt、lab.key重新启动本程序", "info")
os._exit(1)
else:
os._exit(1)
# 加载配置文件
print_status(f"当前工作目录为 {working_dir}", "info")
load_config_from_file(config_path, args_dict["labid"])
load_config_from_file(config_path)
if args_dict["addr"] == "test":
print_status("使用测试环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
elif args_dict["addr"] == "uat":
print_status("使用uat环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
elif args_dict["addr"] == "local":
print_status("使用本地环境地址", "info")
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
else:
HTTPConfig.remote_addr = args_dict.get("addr", "")
# 设置BasicConfig参数
if args_dict.get("ak", ""):
BasicConfig.ak = args_dict.get("ak", "")
print_status("传入了ak参数优先采用传入参数", "info")
if args_dict.get("sk", ""):
BasicConfig.sk = args_dict.get("sk", "")
print_status("传入了sk参数优先采用传入参数", "info")
# 使用远程资源启动
if args_dict["use_remote_resource"]:
print_status("使用远程资源启动", "info")
from unilabos.app.web import http_client
res = http_client.resource_get("host_node", False)
if str(res.get("code", 0)) == "0" and len(res.get("data", [])) > 0:
print_status("远程资源已存在,使用云端物料!", "info")
@@ -215,12 +247,11 @@ def main():
else:
print_status("远程资源不存在,本地将进行首次上报!", "info")
# 设置BasicConfig参数
BasicConfig.working_dir = working_dir
BasicConfig.direct_end = args_dict.get("direct_end", False)
BasicConfig.is_host_mode = not args_dict.get("without_host", False)
BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
BasicConfig.communication_protocol = "websocket"
machine_name = os.popen("hostname").read().strip()
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
BasicConfig.machine_name = machine_name
@@ -230,22 +261,30 @@ def main():
read_node_link_json,
read_graphml,
dict_from_graph,
dict_to_nested_dict,
initialize_resources,
)
from unilabos.app.mq import mqtt_client
from unilabos.app.communication import get_communication_client
from unilabos.registry.registry import build_registry
from unilabos.app.backend import start_backend
from unilabos.app.web import http_client
from unilabos.app.web import start_server
from unilabos.app.register import register_devices_and_resources
# 显示启动横幅
print_unilab_banner(args_dict)
# 注册表
build_registry(args_dict["registry_path"], False, args_dict["upload_registry"])
lab_registry = build_registry(
args_dict["registry_path"], args_dict.get("complete_registry", False), args_dict["upload_registry"]
)
if not BasicConfig.ak or not BasicConfig.sk:
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
os._exit(1)
graph: nx.Graph
resource_tree_set: ResourceTreeSet
resource_links: List[Dict[str, Any]]
request_startup_json = http_client.request_startup_json()
if args_dict["graph"] is None:
request_startup_json = http_client.request_startup_json()
if not request_startup_json:
print_status(
"未指定设备加载文件路径尝试从HTTP获取失败请检查网络或者使用-g参数指定设备加载文件路径", "error"
@@ -253,26 +292,74 @@ def main():
os._exit(1)
else:
print_status("联网获取设备加载文件成功", "info")
graph, data = read_node_link_json(request_startup_json)
graph, resource_tree_set, resource_links = read_node_link_json(request_startup_json)
else:
file_path = args_dict["graph"]
if file_path.endswith(".json"):
graph, data = read_node_link_json(file_path)
graph, resource_tree_set, resource_links = read_node_link_json(file_path)
else:
graph, data = read_graphml(file_path)
graph, resource_tree_set, resource_links = read_graphml(file_path)
import unilabos.resources.graphio as graph_res
graph_res.physical_setup_graph = graph
resource_edge_info = modify_to_backend_format(data["links"])
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["resources_config"] = list(devices_and_resources.values())
args_dict["devices_config"] = dict_to_nested_dict(deepcopy(devices_and_resources), devices_only=False)
resource_edge_info = modify_to_backend_format(resource_links)
materials = lab_registry.obtain_registry_resource_info()
materials.extend(lab_registry.obtain_registry_device_info())
materials = {k["id"]: k for k in materials}
# 从 ResourceTreeSet 中获取节点信息
nodes = {node.res_content.id: node.res_content for node in resource_tree_set.all_nodes}
edge_info = len(resource_edge_info)
for ind, i in enumerate(resource_edge_info[::-1]):
source_node: ResourceDict = nodes[i["source"]]
target_node: ResourceDict = nodes[i["target"]]
source_handle = i["sourceHandle"]
target_handle = i["targetHandle"]
source_handler_keys = [
h["handler_key"] for h in materials[source_node.klass]["handles"] if h["io_type"] == "source"
]
target_handler_keys = [
h["handler_key"] for h in materials[target_node.klass]["handles"] if h["io_type"] == "target"
]
if source_handle not in source_handler_keys:
print_status(
f"节点 {source_node.id} 的source端点 {source_handle} 不存在,请检查,支持的端点 {source_handler_keys}",
"error",
)
resource_edge_info.pop(edge_info - ind - 1)
continue
if target_handle not in target_handler_keys:
print_status(
f"节点 {target_node.id} 的target端点 {target_handle} 不存在,请检查,支持的端点 {target_handler_keys}",
"error",
)
resource_edge_info.pop(edge_info - ind - 1)
continue
# 如果从远端获取了物料信息,则与本地物料进行同步
if request_startup_json and "nodes" in request_startup_json:
print_status("开始同步远端物料到本地...", "info")
remote_tree_set = ResourceTreeSet.from_raw_list(request_startup_json["nodes"])
resource_tree_set.merge_remote_resources(remote_tree_set)
print_status("远端物料同步完成", "info")
# 使用 ResourceTreeSet 代替 list
args_dict["resources_config"] = resource_tree_set
args_dict["devices_config"] = resource_tree_set
args_dict["graph"] = graph_res.physical_setup_graph
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 BasicConfig.upload_registry:
# 设备注册到服务端 - 需要 ak 和 sk
if args_dict.get("ak") and args_dict.get("sk"):
print_status("开始注册设备到服务端...", "info")
try:
register_devices_and_resources(lab_registry)
print_status("设备注册完成", "info")
except Exception as e:
print_status(f"设备注册失败: {e}", "error")
else:
print_status("未提供 ak 和 sk跳过设备注册", "info")
else:
print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning")
if args_dict["controllers"] is not None:
args_dict["controllers_config"] = yaml.safe_load(open(args_dict["controllers"], encoding="utf-8"))
@@ -281,31 +368,37 @@ def main():
args_dict["bridges"] = []
if "mqtt" in args_dict["app_bridges"]:
args_dict["bridges"].append(mqtt_client)
# 获取通信客户端仅支持WebSocket
comm_client = get_communication_client()
if "websocket" in args_dict["app_bridges"]:
args_dict["bridges"].append(comm_client)
if "fastapi" in args_dict["app_bridges"]:
args_dict["bridges"].append(http_client)
if "mqtt" in args_dict["app_bridges"]:
if "websocket" in args_dict["app_bridges"]:
def _exit(signum, frame):
mqtt_client.stop()
comm_client.stop()
sys.exit(0)
signal.signal(signal.SIGINT, _exit)
signal.signal(signal.SIGTERM, _exit)
mqtt_client.start()
comm_client.start()
args_dict["resources_mesh_config"] = {}
args_dict["resources_edge_config"] = resource_edge_info
# web visiualize 2D
if args_dict["visual"] != "disable":
enable_rviz = args_dict["visual"] == "rviz"
devices_and_resources = dict_from_graph(graph_res.physical_setup_graph)
if devices_and_resources is not None:
from unilabos.device_mesh.resource_visalization import (
ResourceVisualization,
) # 此处开启后logger会变更为INFO有需要请调整
resource_visualization = ResourceVisualization(
devices_and_resources, args_dict["resources_config"], enable_rviz=enable_rviz
devices_and_resources,
[n.res_content for n in args_dict["resources_config"].all_nodes], # type: ignore # FIXME
enable_rviz=enable_rviz,
)
args_dict["resources_mesh_config"] = resource_visualization.resource_model
start_backend(**args_dict)

View File

@@ -50,11 +50,16 @@ class Resp(BaseModel):
class JobAddReq(BaseModel):
device_id: str = Field(examples=["Gripper"], description="device id")
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}])
action: str = Field(examples=["_execute_driver_command_async"], description="action name", default="")
action_type: str = Field(examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action name", default="")
action_args: dict = Field(examples=[{'string': 'string'}], description="action name", default="")
task_id: str = Field(examples=["task_id"], description="task uuid")
job_id: str = Field(examples=["job_id"], description="goal uuid")
node_id: str = Field(examples=["node_id"], description="node uuid")
server_info: dict = Field(examples=[{"send_timestamp": 1717000000.0}], description="server info")
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}], default={})
class JobStepFinishReq(BaseModel):
token: str = Field(examples=["030944"], description="token")

View File

@@ -1,221 +0,0 @@
import json
import time
import traceback
from typing import Optional
import uuid
import paho.mqtt.client as mqtt
import ssl
import base64
import hmac
from hashlib import sha1
import tempfile
import os
from unilabos.config.config import MQConfig
from unilabos.app.controler import job_add
from unilabos.app.model import JobAddReq
from unilabos.utils import logger
from unilabos.utils.type_check import TypeEncoder
from paho.mqtt.enums import CallbackAPIVersion
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()}"
logger.info("[MQTT] Client_id: " + self.client_id)
self.client = mqtt.Client(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}")
pass
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)
client.subscribe(f"labs/{MQConfig.lab_id}/pong/", 0)
def _on_message(self, client, userdata, msg) -> None:
# logger.info("[MQTT] on_message<<<< " + msg.topic + " " + str(msg.payload))
try:
payload_str = msg.payload.decode("utf-8")
payload_json = json.loads(payload_str)
if msg.topic == f"labs/{MQConfig.lab_id}/job/start/":
if "data" not in payload_json:
payload_json["data"] = {}
if "action" in payload_json:
payload_json["data"]["action"] = payload_json.pop("action")
if "action_type" in payload_json:
payload_json["data"]["action_type"] = payload_json.pop("action_type")
if "action_args" in payload_json:
payload_json["data"]["action_args"] = payload_json.pop("action_args")
if "action_kwargs" in payload_json:
payload_json["data"]["action_kwargs"] = payload_json.pop("action_kwargs")
job_req = JobAddReq.model_validate(payload_json)
data = job_add(job_req)
return
elif msg.topic == f"labs/{MQConfig.lab_id}/pong/":
# 处理pong响应通知HostNode
from unilabos.ros.nodes.presets.host_node import HostNode
host_instance = HostNode.get_instance(0)
if host_instance:
host_instance.handle_pong_response(payload_json)
return
except json.JSONDecodeError as e:
logger.error(f"[MQTT] JSON 解析错误: {e}")
logger.error(f"[MQTT] Raw message: {msg.payload}")
logger.error(traceback.format_exc())
except Exception as e:
logger.error(f"[MQTT] 处理消息时出错: {e}")
logger.error(traceback.format_exc())
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 Exception as e:
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, "timestamp": time.time()}
address = f"labs/{MQConfig.lab_id}/devices/"
self.client.publish(address, json.dumps(status), qos=2)
# logger.info(f"Device {device_id} status published: address: {address}, {status}")
def publish_job_status(self, feedback_data: dict, job_id: str, status: str, return_info: Optional[str] = None):
if self.mqtt_disable:
return
if return_info is None:
return_info = "{}"
jobdata = {"job_id": job_id, "data": feedback_data, "status": status, "return_info": return_info}
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, print_debug: bool = True):
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)
if print_debug:
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/"
self.client.publish(address, json.dumps(action_info), qos=2)
logger.debug(f"Action data published: address: {address}, {action_id}, {action_info}")
def send_ping(self, ping_id: str, timestamp: float):
"""发送ping消息到服务端"""
if self.mqtt_disable:
return
address = f"labs/{MQConfig.lab_id}/ping/"
ping_data = {"ping_id": ping_id, "client_timestamp": timestamp, "type": "ping"}
self.client.publish(address, json.dumps(ping_data), qos=2)
def setup_pong_subscription(self):
"""设置pong消息订阅"""
if self.mqtt_disable:
return
pong_topic = f"labs/{MQConfig.lab_id}/pong/"
self.client.subscribe(pong_topic, 0)
logger.debug(f"Subscribed to pong topic: {pong_topic}")
def handle_pong(self, pong_data: dict):
"""处理pong响应这个方法会在收到pong消息时被调用"""
logger.debug(f"Pong received: {pong_data}")
# 这里会被HostNode的ping-pong处理逻辑调用
pass
mqtt_client = MQTTClient()
if __name__ == "__main__":
mqtt_client.start()

View File

@@ -1,85 +1,57 @@
import argparse
import json
import time
from unilabos.registry.registry import build_registry
from unilabos.app.main import load_config_from_file
from unilabos.utils.log import logger
from unilabos.utils.type_check import TypeEncoder
def register_devices_and_resources(mqtt_client, lab_registry):
def register_devices_and_resources(lab_registry):
"""
注册设备和资源到 MQTT
注册设备和资源到服务器仅支持HTTP
"""
logger.info("[UniLab Register] 开始注册设备和资源...")
# 注册设备信息
for device_info in lab_registry.obtain_registry_device_info():
mqtt_client.publish_registry(device_info["id"], device_info, False)
logger.debug(f"[UniLab Register] 注册设备: {device_info['id']}")
# # 注册资源信息
# for resource_info in lab_registry.obtain_registry_resource_info():
# mqtt_client.publish_registry(resource_info["id"], resource_info, False)
# logger.debug(f"[UniLab Register] 注册资源: {resource_info['id']}")
# 注册资源信息 - 使用HTTP方式
from unilabos.app.web.client import http_client
logger.info("[UniLab Register] 开始注册设备和资源...")
# 注册设备信息
devices_to_register = {}
for device_info in lab_registry.obtain_registry_device_info():
devices_to_register[device_info["id"]] = json.loads(
json.dumps(device_info, ensure_ascii=False, cls=TypeEncoder)
)
logger.debug(f"[UniLab Register] 收集设备: {device_info['id']}")
resources_to_register = {}
for resource_info in lab_registry.obtain_registry_resource_info():
resources_to_register[resource_info["id"]] = resource_info
logger.debug(f"[UniLab Register] 准备注册资源: {resource_info['id']}")
logger.debug(f"[UniLab Register] 收集资源: {resource_info['id']}")
# 注册设备
if devices_to_register:
try:
start_time = time.time()
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
cost_time = time.time() - start_time
if response.status_code in [200, 201]:
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}ms")
else:
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}ms")
except Exception as e:
logger.error(f"[UniLab Register] 设备注册异常: {e}")
# 注册资源
if resources_to_register:
start_time = time.time()
response = http_client.resource_registry(resources_to_register)
cost_time = time.time() - start_time
if response.status_code in [200, 201]:
logger.info(f"[UniLab Register] 成功通过HTTP注册 {len(resources_to_register)} 个资源 {cost_time}ms")
else:
logger.error(f"[UniLab Register] HTTP注册资源失败: {response.status_code}, {response.text} {cost_time}ms")
try:
start_time = time.time()
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
cost_time = time.time() - start_time
if response.status_code in [200, 201]:
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}ms")
else:
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}ms")
except Exception as e:
logger.error(f"[UniLab Register] 资源注册异常: {e}")
logger.info("[UniLab Register] 设备和资源注册完成.")
def main():
"""
命令行入口函数
"""
parser = argparse.ArgumentParser(description="注册设备和资源到 MQTT")
parser.add_argument(
"--registry",
type=str,
default=None,
action="append",
help="注册表路径",
)
parser.add_argument(
"--config",
type=str,
default=None,
help="配置文件路径,支持.py格式的Python配置文件",
)
parser.add_argument(
"--complete_registry",
action="store_true",
default=False,
help="是否补全注册表",
)
args = parser.parse_args()
load_config_from_file(args.config)
# 构建注册表
build_registry(args.registry, args.complete_registry, True)
from unilabos.app.mq import mqtt_client
# 连接mqtt
mqtt_client.start()
from unilabos.registry.registry import lab_registry
# 注册设备和资源
register_devices_and_resources(mqtt_client, lab_registry)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -3,13 +3,15 @@ HTTP客户端模块
提供与远程服务器通信的客户端功能只有host需要用
"""
import json
import os
from typing import List, Dict, Any, Optional
import requests
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
from unilabos.utils.log import info
from unilabos.config.config import MQConfig, HTTPConfig, BasicConfig
from unilabos.config.config import HTTPConfig, BasicConfig
from unilabos.utils import logger
@@ -24,14 +26,17 @@ class HTTPClient:
remote_addr: 远程服务器地址,如果不提供则从配置中获取
auth: 授权信息
"""
self.initialized = False
self.remote_addr = remote_addr or HTTPConfig.remote_addr
if auth is not None:
self.auth = auth
else:
self.auth = MQConfig.lab_id
auth_secret = BasicConfig.auth_secret()
self.auth = auth_secret
info(f"正在使用ak sk作为授权信息[{auth_secret}]")
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
def resource_edge_add(self, resources: List[Dict[str, Any]], database_process_later: bool) -> requests.Response:
def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
"""
添加资源
@@ -41,33 +46,128 @@ class HTTPClient:
Returns:
Response: API响应对象
"""
database_param = 1 if database_process_later else 0
response = requests.post(
f"{self.remote_addr}/lab/resource/edge/batch_create/?database_process_later={database_param}",
json=resources,
headers={"Authorization": f"lab {self.auth}"},
f"{self.remote_addr}/edge/material/edge",
json={
"edges": resources,
},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
if response.status_code == 200:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"添加物料关系失败: {response.text}")
if response.status_code != 200 and response.status_code != 201:
logger.error(f"添加物料关系失败: {response.status_code}, {response.text}")
return response
def resource_add(self, resources: List[Dict[str, Any]], database_process_later: bool) -> requests.Response:
def resource_tree_add(self, resources: ResourceTreeSet, mount_uuid: str, first_add: bool) -> Dict[str, str]:
"""
添加资源
Args:
resources: 要添加的资源树集合ResourceTreeSet
mount_uuid: 要挂载的资源的uuid
first_add: 是否为首次添加资源可以是host也可以是slave来的
Returns:
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
"""
# 从序列化数据中提取所有节点的UUID保存旧UUID
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
if not self.initialized or first_add:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
f"{self.remote_addr}/edge/material",
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
else:
response = requests.put(
f"{self.remote_addr}/edge/material",
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
# 处理响应构建UUID映射
uuid_mapping = {}
if response.status_code == 200:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"添加物料失败: {response.text}")
else:
data = res["data"]
for i in data:
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
else:
logger.error(f"添加物料失败: {response.text}")
for u, n in old_uuids.items():
if u in uuid_mapping:
n.res_content.uuid = uuid_mapping[u]
for c in n.children:
c.res_content.parent_uuid = n.res_content.uuid
else:
logger.warning(f"资源UUID未更新: {u}")
return uuid_mapping
def resource_tree_get(self, uuid_list: List[str], with_children: bool) -> List[Dict[str, Any]]:
"""
添加资源
Args:
uuid_list: List[str]
Returns:
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
"""
response = requests.post(
f"{self.remote_addr}/edge/material/query",
json={"uuids": uuid_list, "with_children": with_children},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
if response.status_code == 200:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"查询物料失败: {response.text}")
else:
data = res["data"]["nodes"]
return data
else:
logger.error(f"查询物料失败: {response.text}")
return []
def resource_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
"""
添加资源
Args:
resources: 要添加的资源列表
database_process_later: 后台处理资源
Returns:
Response: API响应对象
"""
response = requests.post(
f"{self.remote_addr}/lab/resource/?database_process_later={1 if database_process_later else 0}",
json=resources,
headers={"Authorization": f"lab {self.auth}"},
timeout=100,
)
if not self.initialized:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
else:
response = requests.put(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
if response.status_code == 200:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"添加物料失败: {response.text}")
if response.status_code != 200:
logger.error(f"添加物料失败: {response.text}")
return response
@@ -84,9 +184,9 @@ class HTTPClient:
Dict: 返回的资源数据
"""
response = requests.get(
f"{self.remote_addr}/lab/resource/?edge_format=1",
f"{self.remote_addr}/lab/material",
params={"id": id, "with_children": with_children},
headers={"Authorization": f"lab {self.auth}"},
headers={"Authorization": f"Lab {self.auth}"},
timeout=20,
)
return response.json()
@@ -104,7 +204,7 @@ class HTTPClient:
response = requests.delete(
f"{self.remote_addr}/lab/resource/batch_delete/",
params={"id": id},
headers={"Authorization": f"lab {self.auth}"},
headers={"Authorization": f"Lab {self.auth}"},
timeout=20,
)
return response
@@ -119,13 +219,29 @@ class HTTPClient:
Returns:
Response: API响应对象
"""
response = requests.patch(
f"{self.remote_addr}/lab/resource/batch_update/?edge_format=1",
json=resources,
headers={"Authorization": f"lab {self.auth}"},
timeout=100,
)
return response
if not self.initialized:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
else:
response = requests.put(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
if response.status_code == 200:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"添加物料失败: {response.text}")
if response.status_code != 200:
logger.error(f"添加物料失败: {response.text}")
return response.json()
def upload_file(self, file_path: str, scene: str = "models") -> requests.Response:
"""
@@ -146,25 +262,25 @@ class HTTPClient:
response = requests.post(
f"{self.remote_addr}/api/account/file_upload/{scene}",
files=files,
headers={"Authorization": f"lab {self.auth}"},
headers={"Authorization": f"Lab {self.auth}"},
timeout=30, # 上传文件可能需要更长的超时时间
)
return response
def resource_registry(self, registry_data: Dict[str, Any]) -> requests.Response:
def resource_registry(self, registry_data: Dict[str, Any] | List[Dict[str, Any]]) -> requests.Response:
"""
注册资源到服务器
Args:
registry_data: 注册表数据,格式为 {resource_id: resource_info}
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
Returns:
Response: API响应对象
"""
response = requests.post(
f"{self.remote_addr}/lab/registry/",
f"{self.remote_addr}/lab/resource",
json=registry_data,
headers={"Authorization": f"lab {self.auth}"},
headers={"Authorization": f"Lab {self.auth}"},
timeout=30,
)
if response.status_code not in [200, 201]:
@@ -182,8 +298,8 @@ class HTTPClient:
Response: API响应对象
"""
response = requests.get(
f"{self.remote_addr}/lab/resource/graph_info/",
headers={"Authorization": f"lab {self.auth}"},
f"{self.remote_addr}/edge/material/download",
headers={"Authorization": f"Lab {self.auth}"},
timeout=(3, 30),
)
if response.status_code != 200:

View File

@@ -78,21 +78,23 @@ def setup_web_pages(router: APIRouter) -> None:
HTMLResponse: 渲染后的HTML页面
"""
try:
# 准备设备数据
# 准备初始数据结构这些数据将通过WebSocket实时更新
devices = []
resources = []
modules = {"names": [], "classes": [], "displayed_count": 0, "total_count": 0}
# 获取在线设备信息
# 获取在线设备信息(用于初始渲染)
ros_node_info = get_ros_node_info()
# 获取主机节点信息
# 获取主机节点信息(用于初始渲染)
host_node_info = get_host_node_info()
# 获取Registry路径信息
# 获取Registry路径信息(静态信息,不需要实时更新)
registry_info = get_registry_info()
# 获取已加载的设备
# 获取初始数据用于页面渲染后续将被WebSocket数据覆盖
if lab_registry:
devices = json.loads(json.dumps(lab_registry.obtain_registry_device_info(), ensure_ascii=False, cls=TypeEncoder))
devices = json.loads(
json.dumps(lab_registry.obtain_registry_device_info(), ensure_ascii=False, cls=TypeEncoder)
)
# 资源类型
for resource_id, resource_info in lab_registry.resource_type_registry.items():
resources.append(
@@ -103,7 +105,7 @@ def setup_web_pages(router: APIRouter) -> None:
}
)
# 获取导入的模块
# 获取导入的模块(初始数据)
if msg_converter_manager:
modules["names"] = msg_converter_manager.list_modules()
all_classes = [i for i in msg_converter_manager.list_classes() if "." in i]
@@ -171,3 +173,20 @@ def setup_web_pages(router: APIRouter) -> None:
except Exception as e:
error(f"打开文件夹时出错: {str(e)}")
return {"status": "error", "message": f"Failed to open folder: {str(e)}"}
@router.get("/registry-editor", response_class=HTMLResponse, summary="Registry Editor")
async def registry_editor_page() -> str:
"""
注册表编辑页面用于导入Python文件并生成注册表
Returns:
HTMLResponse: 渲染后的HTML页面
"""
try:
# 使用模板渲染页面
template = env.get_template("registry_editor.html")
html = template.render()
return html
except Exception as e:
error(f"生成注册表编辑页面时出错: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error generating registry editor page: {str(e)}")

View File

@@ -162,7 +162,6 @@
<body>
<h1>{% block header %}UniLab{% endblock %}</h1>
{% block nav %}
<a href="/unilabos/webtic" class="home-link">Home</a>
{% endblock %}
{% block top_info %}{% endblock %}

View File

@@ -1,22 +1,25 @@
{% extends "base.html" %}
{% block title %}UniLab API{% endblock %}
{% block header %}UniLab API{% endblock %}
{% block nav %}
<a href="/status" class="status-link">System Status</a>
{% endblock %}
{% block content %}
<div class="card">
<h2>Available Endpoints</h2>
{% for route in routes %}
<div class="endpoint">
<span class="method">{{ route.method }}</span>
<a href="{{ route.path }}">{{ route.path }}</a>
<p>{{ route.summary }}</p>
</div>
{% endfor %}
{% extends "base.html" %} {% block title %}UniLab API{% endblock %} {% block
header %}UniLab API{% endblock %} {% block nav %}
<div class="nav-tabs">
<a
href="/"
class="nav-tab"
style="background-color: #2196f3; color: white"
target="_blank"
>主页</a
>
<a href="/status" class="nav-tab">状态</a>
<a href="/registry-editor" class="nav-tab" target="_blank">注册表编辑</a>
</div>
{% endblock %}
{% endblock %} {% block content %}
<div class="card">
<h2>Available Endpoints</h2>
{% for route in routes %}
<div class="endpoint">
<span class="method">{{ route.method }}</span>
<a href="{{ route.path }}">{{ route.path }}</a>
<p>{{ route.summary }}</p>
</div>
{% endfor %}
</div>
{% endblock %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1270
unilabos/app/ws_client.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -46,6 +46,7 @@ action_protocol_generators = {
HeatChillStopProtocol: generate_heat_chill_stop_protocol,
HydrogenateProtocol: generate_hydrogenate_protocol,
PumpTransferProtocol: generate_pump_protocol_with_rinsing,
TransferProtocol: generate_pump_protocol,
RecrystallizeProtocol: generate_recrystallize_protocol,
ResetHandlingProtocol: generate_reset_handling_protocol,
RunColumnProtocol: generate_run_column_protocol,

View File

@@ -155,7 +155,7 @@ def generate_add_protocol(
"device_id": stirrer_id,
"action_name": "start_stir",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"stir_speed": stir_speed,
"purpose": f"准备添加固体 {reagent}"
}
@@ -169,7 +169,7 @@ def generate_add_protocol(
# 固体加样
add_kwargs = {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"reagent": reagent,
"purpose": purpose,
"event": event,
@@ -232,7 +232,7 @@ def generate_add_protocol(
"device_id": stirrer_id,
"action_name": "start_stir",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"stir_speed": stir_speed,
"purpose": f"准备添加液体 {reagent}"
}

View File

@@ -325,7 +325,7 @@ def generate_adjust_ph_protocol(
"device_id": stirrer_id,
"action_name": "start_stir",
"action_kwargs": {
"vessel": vessel_id,
"vessel": {"id": vessel_id},
"stir_speed": stir_speed,
"purpose": f"pH调节: 启动搅拌,准备添加 {reagent}"
}

View File

@@ -156,7 +156,7 @@ def generate_centrifuge_protocol(
"device_id": centrifuge_id,
"action_name": "centrifuge",
"action_kwargs": {
"vessel": centrifuge_vessel,
"vessel": {"id": centrifuge_vessel},
"speed": speed,
"time": time,
"temp": temp

View File

@@ -143,7 +143,7 @@ def generate_clean_vessel_protocol(
"device_id": heatchill_id,
"action_name": "heat_chill_start",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"temp": temp,
"purpose": f"cleaning with {solvent}"
}
@@ -295,7 +295,7 @@ def generate_clean_vessel_protocol(
"device_id": heatchill_id,
"action_name": "heat_chill_stop",
"action_kwargs": {
"vessel": vessel_id # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
}
}
action_sequence.append(heatchill_stop_action)

View File

@@ -563,7 +563,7 @@ def generate_dissolve_protocol(
"device_id": heatchill_id,
"action_name": "heat_chill_start",
"action_kwargs": {
"vessel": vessel_id,
"vessel": {"id": vessel_id},
"temp": final_temp,
"purpose": f"溶解准备 - {event}" if event else "溶解准备"
}
@@ -587,7 +587,7 @@ def generate_dissolve_protocol(
"device_id": stirrer_id,
"action_name": "start_stir",
"action_kwargs": {
"vessel": vessel_id,
"vessel": {"id": vessel_id},
"stir_speed": stir_speed,
"purpose": f"溶解搅拌 - {event}" if event else "溶解搅拌"
}
@@ -612,7 +612,7 @@ def generate_dissolve_protocol(
# 固体加样
add_kwargs = {
"vessel": vessel_id,
"vessel": {"id": vessel_id},
"reagent": reagent or amount or "solid reagent",
"purpose": f"溶解固体试剂 - {event}" if event else "溶解固体试剂",
"event": event
@@ -758,7 +758,7 @@ def generate_dissolve_protocol(
"device_id": heatchill_id,
"action_name": "heat_chill",
"action_kwargs": {
"vessel": vessel_id,
"vessel": {"id": vessel_id},
"temp": final_temp,
"time": final_time,
"stir": True,
@@ -776,7 +776,7 @@ def generate_dissolve_protocol(
"device_id": stirrer_id,
"action_name": "stir",
"action_kwargs": {
"vessel": vessel_id,
"vessel": {"id": vessel_id},
"stir_time": final_time,
"stir_speed": stir_speed,
"settling_time": 0,
@@ -802,7 +802,7 @@ def generate_dissolve_protocol(
"device_id": heatchill_id,
"action_name": "heat_chill_stop",
"action_kwargs": {
"vessel": vessel_id
"vessel": {"id": vessel_id},
}
}
action_sequence.append(stop_action)

View File

@@ -167,7 +167,7 @@ def generate_dry_protocol(
"device_id": heater_id,
"action_name": "heat_chill_start",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"temp": dry_temp,
"purpose": f"干燥 {compound or '化合物'}"
}
@@ -191,7 +191,7 @@ def generate_dry_protocol(
"device_id": heater_id,
"action_name": "heat_chill",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"temp": dry_temp,
"time": simulation_time,
"purpose": f"干燥 {compound or '化合物'},保持温度 {dry_temp}°C"
@@ -251,7 +251,7 @@ def generate_dry_protocol(
"device_id": heater_id,
"action_name": "heat_chill_stop",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"purpose": f"干燥完成,停止加热"
}
})

View File

@@ -452,7 +452,7 @@ def generate_evacuateandrefill_protocol(
"device_id": stirrer_id,
"action_name": "start_stir",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"stir_speed": STIR_SPEED,
"purpose": "抽真空充气前预搅拌"
}
@@ -685,7 +685,7 @@ def generate_evacuateandrefill_protocol(
action_sequence.append({
"device_id": stirrer_id,
"action_name": "stop_stir",
"action_kwargs": {"vessel": vessel_id} # 🔧 使用 vessel_id
"action_kwargs": {"vessel": {"id": vessel_id},} # 🔧 使用 vessel_id
})
else:
action_sequence.append(create_action_log("跳过搅拌器停止", "⏭️"))

View File

@@ -329,7 +329,7 @@ def generate_evaporate_protocol(
"device_id": rotavap_device,
"action_name": "evaporate",
"action_kwargs": {
"vessel": target_vessel,
"vessel": {"id": target_vessel},
"pressure": float(pressure),
"temp": float(temp),
"time": float(final_time), # 🔧 强制转换为float类型

View File

@@ -220,7 +220,7 @@ def generate_heat_chill_protocol(
"device_id": heatchill_id,
"action_name": "heat_chill",
"action_kwargs": {
"vessel": vessel,
"vessel": {"id": vessel},
"temp": float(final_temp),
"time": float(final_time),
"stir": bool(stir),
@@ -287,7 +287,8 @@ def generate_heat_chill_start_protocol(
"action_name": "heat_chill_start",
"action_kwargs": {
"temp": temp,
"purpose": purpose or f"开始加热到 {temp}°C"
"purpose": purpose or f"开始加热到 {temp}°C",
"vessel": {"id": vessel_id},
}
}]

View File

@@ -265,7 +265,7 @@ def generate_separate_protocol(
"device_id": stirrer_device,
"action_name": "start_stir",
"action_kwargs": {
"vessel": final_vessel_id, # 🔧 使用 final_vessel_id
"vessel": {"id": final_vessel_id}, # 🔧 使用 final_vessel_id
"stir_speed": stir_speed,
"purpose": f"分离混合 - {purpose}"
}

View File

@@ -234,7 +234,7 @@ def generate_stir_protocol(
"action_name": "stir",
"action_kwargs": {
# 🔧 关键修复传递vessel_id字符串而不是完整的Resource对象
"vessel": vessel_id, # 传递字符串ID不是Resource对象
"vessel": {"id": vessel_id}, # 传递字符串ID不是Resource对象
"time": str(time),
"event": event,
"time_spec": time_spec,
@@ -323,7 +323,7 @@ def generate_start_stir_protocol(
"action_name": "start_stir",
"action_kwargs": {
# 🔧 关键修复传递vessel_id字符串而不是完整的Resource对象
"vessel": vessel_id, # 传递字符串ID不是Resource对象
"vessel": {"id": vessel_id}, # 传递字符串ID不是Resource对象
"stir_speed": stir_speed,
"purpose": purpose or f"启动搅拌 {stir_speed} RPM"
}
@@ -383,7 +383,7 @@ def generate_stop_stir_protocol(
"action_name": "stop_stir",
"action_kwargs": {
# 🔧 关键修复传递vessel_id字符串而不是完整的Resource对象
"vessel": vessel_id # 传递字符串ID不是Resource对象
"vessel": {"id": vessel_id}, # 传递字符串ID不是Resource对象
}
}]

View File

@@ -361,7 +361,7 @@ def generate_wash_solid_protocol(
"device_id": "stirrer_1",
"action_name": "stir",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"time": str(time),
"stir_time": final_time,
"stir_speed": stir_speed,
@@ -377,7 +377,7 @@ def generate_wash_solid_protocol(
"device_id": "filter_1",
"action_name": "filter",
"action_kwargs": {
"vessel": vessel_id, # 🔧 使用 vessel_id
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
"filtrate_vessel": actual_filtrate_vessel,
"temp": temp,
"volume": final_volume

View File

@@ -1,14 +1,14 @@
#!/usr/bin/env python
# coding=utf-8
# 定义配置变量和加载函数
import base64
import traceback
import os
import importlib.util
from typing import Optional
from unilabos.utils import logger
class BasicConfig:
ENV = "pro" # 'test'
ak = ""
sk = ""
working_dir = ""
config_path = ""
is_host_mode = True
@@ -17,26 +17,22 @@ class BasicConfig:
machine_name = "undefined"
vis_2d_enable = False
enable_resource_load = True
direct_end = False
communication_protocol = "websocket"
@classmethod
def auth_secret(cls):
if not cls.ak or not cls.sk:
return ""
target = f"{cls.ak}:{cls.sk}"
base64_target = base64.b64encode(target.encode("utf-8")).decode("utf-8")
return base64_target
# 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 = "" # 相对config.py所在目录的路径
cert_file = "" # 相对config.py所在目录的路径
key_file = "" # 相对config.py所在目录的路径
# WebSocket配置
class WSConfig:
reconnect_interval = 5 # 重连间隔(秒)
max_reconnect_attempts = 999 # 最大重连次数
ping_interval = 30 # ping间隔
# OSS上传配置
@@ -66,48 +62,13 @@ class ROSConfig:
]
def _update_config_from_module(module, override_labid: str):
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 override_labid:
MQConfig.lab_id = override_labid
logger.warning(f"[ENV] 当前实验室启动的ID被设置为{override_labid}")
if len(MQConfig.ca_content) == 0:
# 需要先判断是否为相对路径
if MQConfig.ca_file.startswith("."):
MQConfig.ca_file = os.path.join(BasicConfig.config_path, MQConfig.ca_file)
if len(MQConfig.ca_file) != 0:
with open(MQConfig.ca_file, "r", encoding="utf-8") as f:
MQConfig.ca_content = f.read()
else:
logger.warning("Skipping CA file loading, ca_file is empty")
if len(MQConfig.cert_content) == 0:
# 需要先判断是否为相对路径
if MQConfig.cert_file.startswith("."):
MQConfig.cert_file = os.path.join(BasicConfig.config_path, MQConfig.cert_file)
if len(MQConfig.ca_file) != 0:
with open(MQConfig.cert_file, "r", encoding="utf-8") as f:
MQConfig.cert_content = f.read()
else:
logger.warning("Skipping cert file loading, cert_file is empty")
if len(MQConfig.key_content) == 0:
# 需要先判断是否为相对路径
if MQConfig.key_file.startswith("."):
MQConfig.key_file = os.path.join(BasicConfig.config_path, MQConfig.key_file)
if len(MQConfig.ca_file) != 0:
with open(MQConfig.key_file, "r", encoding="utf-8") as f:
MQConfig.key_content = f.read()
else:
logger.warning("Skipping key file loading, key_file is empty")
def _update_config_from_env():
prefix = "UNILABOS_"
@@ -160,8 +121,7 @@ def _update_config_from_env():
logger.warning(f"[ENV] 解析环境变量 {env_key} 失败: {e}")
def load_config(config_path=None, override_labid=None):
def load_config(config_path=None):
# 如果提供了配置文件路径,从该文件导入配置
if config_path:
env_config_path = os.environ.get("UNILABOS_BASICCONFIG_CONFIG_PATH")
@@ -178,7 +138,7 @@ def load_config(config_path=None, override_labid=None):
return
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # type: ignore
_update_config_from_module(module, override_labid)
_update_config_from_module(module)
logger.info(f"[ENV] 配置文件 {config_path} 加载成功")
_update_config_from_env()
except Exception as e:
@@ -187,4 +147,4 @@ def load_config(config_path=None, override_labid=None):
exit(1)
else:
config_path = os.path.join(os.path.dirname(__file__), "local_config.py")
load_config(config_path, override_labid)
load_config(config_path)

View File

@@ -1,17 +1,12 @@
# MQTT配置
class MQConfig:
lab_id = ""
instance_id = ""
access_key = ""
secret_key = ""
group_id = ""
broker_url = ""
port = 1883
# unilabos的配置文件
ca_file = "./CA.crt"
cert_file = "./lab.crt"
key_file = "./lab.key"
class BasicConfig:
ak = "" # 实验室网页给您提供的ak代码您可以在配置文件中指定也可以通过运行unilabos时以 --ak 传入,优先按照传入参数解析
sk = "" # 实验室网页给您提供的sk代码您可以在配置文件中指定也可以通过运行unilabos时以 --sk 传入,优先按照传入参数解析
# HTTP配置
class HTTPConfig:
remote_addr = "https://uni-lab.bohrium.com/api/v1"
# WebSocket配置一般无需调整
class WSConfig:
reconnect_interval = 5 # 重连间隔(秒)
max_reconnect_attempts = 999 # 最大重连次数
ping_interval = 30 # ping间隔

View File

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

View File

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

View File

@@ -0,0 +1,454 @@
"""
纽扣电池组装工作站
Coin Cell Assembly Workstation
继承工作站基类,实现纽扣电池特定功能
"""
from typing import Dict, Any, List, Optional, Union
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
from unilabos.device_comms.workstation_base import WorkstationBase, WorkflowInfo
from unilabos.device_comms.workstation_communication import (
WorkstationCommunicationBase, CommunicationConfig, CommunicationProtocol, CoinCellCommunication
)
from unilabos.device_comms.workstation_material_management import (
MaterialManagementBase, CoinCellMaterialManagement
)
from unilabos.utils.log import logger
class CoinCellAssemblyWorkstation(WorkstationBase):
"""纽扣电池组装工作站
基于工作站基类,实现纽扣电池制造的特定功能:
1. 纽扣电池特定的通信协议
2. 纽扣电池物料管理(料板、极片、电池等)
3. 电池制造工作流
4. 质量检查工作流
"""
def __init__(
self,
device_id: str,
children: Dict[str, Dict[str, Any]],
protocol_type: Union[str, List[str]] = "BatteryManufacturingProtocol",
resource_tracker: Optional[DeviceNodeResourceTracker] = None,
modbus_config: Optional[Dict[str, Any]] = None,
deck_config: Optional[Dict[str, Any]] = None,
csv_path: str = "./coin_cell_assembly.csv",
*args,
**kwargs,
):
# 设置通信配置
modbus_config = modbus_config or {"host": "127.0.0.1", "port": 5021}
self.communication_config = CommunicationConfig(
protocol=CommunicationProtocol.MODBUS_TCP,
host=modbus_config["host"],
port=modbus_config["port"],
timeout=modbus_config.get("timeout", 5.0),
retry_count=modbus_config.get("retry_count", 3)
)
# 设置台面配置
self.deck_config = deck_config or {
"size_x": 1620.0,
"size_y": 1270.0,
"size_z": 500.0
}
# CSV地址映射文件路径
self.csv_path = csv_path
# 创建资源跟踪器(如果没有提供)
if resource_tracker is None:
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
resource_tracker = DeviceNodeResourceTracker()
# 初始化基类
super().__init__(
device_id=device_id,
children=children,
protocol_type=protocol_type,
resource_tracker=resource_tracker,
communication_config=self.communication_config,
deck_config=self.deck_config,
*args,
**kwargs
)
logger.info(f"纽扣电池组装工作站 {device_id} 初始化完成")
def _create_communication_module(self) -> WorkstationCommunicationBase:
"""创建纽扣电池通信模块"""
return CoinCellCommunication(
communication_config=self.communication_config,
csv_path=self.csv_path
)
def _create_material_management_module(self) -> MaterialManagementBase:
"""创建纽扣电池物料管理模块"""
return CoinCellMaterialManagement(
device_id=self.device_id,
deck_config=self.deck_config,
resource_tracker=self.resource_tracker,
children_config=self.children
)
def _register_supported_workflows(self):
"""注册纽扣电池工作流"""
# 电池制造工作流
self.supported_workflows["battery_manufacturing"] = WorkflowInfo(
name="battery_manufacturing",
description="纽扣电池制造工作流",
estimated_duration=300.0, # 5分钟
required_materials=["cathode_sheet", "anode_sheet", "separator", "electrolyte"],
output_product="coin_cell_battery",
parameters_schema={
"type": "object",
"properties": {
"electrolyte_num": {
"type": "integer",
"description": "电解液瓶数",
"minimum": 1,
"maximum": 32
},
"electrolyte_volume": {
"type": "number",
"description": "电解液体积 (μL)",
"minimum": 0.1,
"maximum": 100.0
},
"assembly_pressure": {
"type": "number",
"description": "组装压力 (N)",
"minimum": 100.0,
"maximum": 5000.0
},
"cathode_material": {
"type": "string",
"description": "正极材料类型",
"enum": ["LiFePO4", "LiCoO2", "NCM", "LMO"]
},
"anode_material": {
"type": "string",
"description": "负极材料类型",
"enum": ["Graphite", "LTO", "Silicon"]
}
},
"required": ["electrolyte_num", "electrolyte_volume", "assembly_pressure"]
}
)
# 质量检查工作流
self.supported_workflows["quality_inspection"] = WorkflowInfo(
name="quality_inspection",
description="产品质量检查工作流",
estimated_duration=60.0, # 1分钟
required_materials=["finished_battery"],
output_product="quality_report",
parameters_schema={
"type": "object",
"properties": {
"test_voltage": {
"type": "boolean",
"description": "是否测试电压",
"default": True
},
"test_capacity": {
"type": "boolean",
"description": "是否测试容量",
"default": False
},
"voltage_threshold": {
"type": "number",
"description": "电压阈值 (V)",
"minimum": 2.0,
"maximum": 4.5,
"default": 3.0
}
}
}
)
# 设备初始化工作流
self.supported_workflows["device_initialization"] = WorkflowInfo(
name="device_initialization",
description="设备初始化工作流",
estimated_duration=30.0, # 30秒
required_materials=[],
output_product="ready_status",
parameters_schema={
"type": "object",
"properties": {
"auto_mode": {
"type": "boolean",
"description": "是否启用自动模式",
"default": True
}
}
}
)
# ============ 纽扣电池特定方法 ============
def get_electrode_sheet_inventory(self) -> Dict[str, int]:
"""获取极片库存统计"""
try:
sheets = self.material_management.find_electrode_sheets()
inventory = {}
for sheet in sheets:
material_type = getattr(sheet, 'material_type', 'unknown')
inventory[material_type] = inventory.get(material_type, 0) + 1
return inventory
except Exception as e:
logger.error(f"获取极片库存失败: {e}")
return {}
def get_battery_production_statistics(self) -> Dict[str, Any]:
"""获取电池生产统计"""
try:
production_data = self.communication.get_production_data()
# 添加物料统计
electrode_inventory = self.get_electrode_sheet_inventory()
battery_count = len(self.material_management.find_batteries())
return {
**production_data,
"electrode_inventory": electrode_inventory,
"finished_battery_count": battery_count,
"material_plates": len(self.material_management.find_material_plates()),
"press_slots": len(self.material_management.find_press_slots())
}
except Exception as e:
logger.error(f"获取生产统计失败: {e}")
return {"error": str(e)}
def create_new_battery(self, battery_spec: Dict[str, Any]) -> Optional[str]:
"""创建新电池资源"""
try:
from unilabos.device_comms.button_battery_station import Battery
import uuid
battery_id = f"battery_{uuid.uuid4().hex[:8]}"
battery = Battery(
name=battery_id,
diameter=battery_spec.get("diameter", 20.0),
height=battery_spec.get("height", 3.2),
max_volume=battery_spec.get("max_volume", 100.0),
barcode=battery_spec.get("barcode", "")
)
# 添加到物料管理系统
self.material_management.plr_resources[battery_id] = battery
self.material_management.resource_tracker.add_resource(battery)
logger.info(f"创建新电池资源: {battery_id}")
return battery_id
except Exception as e:
logger.error(f"创建电池资源失败: {e}")
return None
def find_available_press_slot(self) -> Optional[str]:
"""查找可用的压制槽"""
try:
press_slots = self.material_management.find_press_slots()
for slot in press_slots:
if hasattr(slot, 'has_battery') and not slot.has_battery():
return slot.name
return None
except Exception as e:
logger.error(f"查找可用压制槽失败: {e}")
return None
def get_glove_box_environment(self) -> Dict[str, Any]:
"""获取手套箱环境数据"""
try:
device_status = self.communication.get_device_status()
environment = device_status.get("environment", {})
return {
"pressure": environment.get("glove_box_pressure", 0.0),
"o2_content": environment.get("o2_content", 0.0),
"water_content": environment.get("water_content", 0.0),
"is_safe": (
environment.get("o2_content", 0.0) < 10.0 and # 氧气含量 < 10ppm
environment.get("water_content", 0.0) < 1.0 # 水分含量 < 1ppm
)
}
except Exception as e:
logger.error(f"获取手套箱环境失败: {e}")
return {"error": str(e)}
def start_data_export(self, file_path: str) -> bool:
"""开始生产数据导出"""
try:
return self.communication.start_data_export(file_path, export_interval=5.0)
except Exception as e:
logger.error(f"启动数据导出失败: {e}")
return False
def stop_data_export(self) -> bool:
"""停止生产数据导出"""
try:
return self.communication.stop_data_export()
except Exception as e:
logger.error(f"停止数据导出失败: {e}")
return False
# ============ 重写基类方法以支持纽扣电池特定功能 ============
def start_workflow(self, workflow_type: str, parameters: Dict[str, Any] = None) -> bool:
"""启动工作流(重写以支持纽扣电池特定预处理)"""
try:
# 进行纽扣电池特定的预检查
if workflow_type == "battery_manufacturing":
# 检查手套箱环境
env = self.get_glove_box_environment()
if not env.get("is_safe", False):
logger.error("手套箱环境不安全,无法启动电池制造工作流")
return False
# 检查是否有可用的压制槽
available_slot = self.find_available_press_slot()
if not available_slot:
logger.error("没有可用的压制槽,无法启动电池制造工作流")
return False
# 检查极片库存
electrode_inventory = self.get_electrode_sheet_inventory()
if not electrode_inventory.get("cathode", 0) > 0 or not electrode_inventory.get("anode", 0) > 0:
logger.error("极片库存不足,无法启动电池制造工作流")
return False
# 调用基类方法
return super().start_workflow(workflow_type, parameters)
except Exception as e:
logger.error(f"启动纽扣电池工作流失败: {e}")
return False
# ============ 纽扣电池特定状态属性 ============
@property
def electrode_sheet_count(self) -> int:
"""极片总数"""
try:
return len(self.material_management.find_electrode_sheets())
except:
return 0
@property
def battery_count(self) -> int:
"""电池总数"""
try:
return len(self.material_management.find_batteries())
except:
return 0
@property
def available_press_slots(self) -> int:
"""可用压制槽数"""
try:
press_slots = self.material_management.find_press_slots()
available = 0
for slot in press_slots:
if hasattr(slot, 'has_battery') and not slot.has_battery():
available += 1
return available
except:
return 0
@property
def environment_status(self) -> Dict[str, Any]:
"""环境状态"""
return self.get_glove_box_environment()
# ============ 工厂函数 ============
def create_coin_cell_workstation(
device_id: str,
config_file: str,
modbus_host: str = "127.0.0.1",
modbus_port: int = 5021,
csv_path: str = "./coin_cell_assembly.csv"
) -> CoinCellAssemblyWorkstation:
"""工厂函数:创建纽扣电池组装工作站
Args:
device_id: 设备ID
config_file: 配置文件路径JSON格式
modbus_host: Modbus主机地址
modbus_port: Modbus端口
csv_path: 地址映射CSV文件路径
Returns:
CoinCellAssemblyWorkstation: 工作站实例
"""
import json
try:
# 加载配置文件
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
# 提取配置
children = config.get("children", {})
deck_config = config.get("deck_config", {})
# 创建工作站
workstation = CoinCellAssemblyWorkstation(
device_id=device_id,
children=children,
modbus_config={
"host": modbus_host,
"port": modbus_port
},
deck_config=deck_config,
csv_path=csv_path
)
logger.info(f"纽扣电池工作站创建成功: {device_id}")
return workstation
except Exception as e:
logger.error(f"创建纽扣电池工作站失败: {e}")
raise
if __name__ == "__main__":
# 示例用法
workstation = create_coin_cell_workstation(
device_id="coin_cell_station_01",
config_file="./button_battery_workstation.json",
modbus_host="127.0.0.1",
modbus_port=5021
)
# 启动电池制造工作流
success = workstation.start_workflow(
"battery_manufacturing",
{
"electrolyte_num": 16,
"electrolyte_volume": 50.0,
"assembly_pressure": 2000.0,
"cathode_material": "LiFePO4",
"anode_material": "Graphite"
}
)
if success:
print("电池制造工作流启动成功")
else:
print("电池制造工作流启动失败")

View File

@@ -8,8 +8,8 @@ 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.modbus_plc.modbus import DeviceType, HoldRegister, Coil, InputRegister, DiscreteInputs, DataType, WorderOrder
from unilabos.device_comms.modbus_plc.modbus import Base as ModbusNodeBase
from unilabos.device_comms.universal_driver import UniversalDriver
from unilabos.utils.log import logger
import pandas as pd

View File

@@ -1,6 +1,6 @@
import time
from pymodbus.client import ModbusTcpClient
from unilabos.device_comms.modbus_plc.node.modbus import Coil, HoldRegister
from unilabos.device_comms.modbus_plc.modbus import Coil, HoldRegister
from pymodbus.payload import BinaryPayloadDecoder
from pymodbus.constants import Endian

View File

@@ -1,6 +1,6 @@
# coding=utf-8
from pymodbus.client import ModbusTcpClient
from unilabos.device_comms.modbus_plc.node.modbus import Coil
from unilabos.device_comms.modbus_plc.modbus import Coil
import time

View File

@@ -1,7 +1,7 @@
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
from unilabos.device_comms.modbus_plc.modbus import Base as ModbusNodeBase
############ 第一种写法 ##############

View File

@@ -0,0 +1,150 @@
<?xml version="1.0" ?>
<robot name="liquid_transform_xyz" xmlns:xacro="http://www.ros.org/wiki/xacro">
<xacro:macro name="liquid_transform_xyz" params="mesh_path:='' parent_link:='' station_name:='' device_name:='' x:=0 y:=0 z:=0 rx:=0 ry:=0 r:=0">
<joint name="${station_name}${device_name}base_link_joint" type="fixed">
<origin xyz="${x} ${y} ${z}" rpy="${rx} ${ry} ${r}" />
<parent link="${parent_link}"/>
<child link="${station_name}${device_name}device_link"/>
<axis xyz="0 0 0"/>
</joint>
<link name="${station_name}${device_name}device_link"/>
<joint name="${station_name}${device_name}device_link_joint" type="fixed">
<origin xyz="0 0 0" rpy="0 0 0" />
<parent link="${station_name}${device_name}device_link"/>
<child link="${station_name}${device_name}base_link"/>
<axis xyz="0 0 0"/>
</joint>
<!-- =================================================================================== -->
<!-- | This document was autogenerated by xacro from xyz.urdf | -->
<!-- | EDITING THIS FILE BY HAND IS NOT RECOMMENDED | -->
<!-- =================================================================================== -->
<!-- This URDF was automatically created by SolidWorks to URDF Exporter! Originally created by Stephen Brawner (brawner@gmail.com)
Commit Version: 1.6.0-4-g7f85cfe Build Version: 1.6.7995.38578
For more information, please see http://wiki.ros.org/sw_urdf_exporter -->
<link name="${station_name}${device_name}base_link">
<inertial>
<origin rpy="0 0 0" xyz="0.15478184748283 0.171048654921622 0.119246989054835"/>
<mass value="10.6178517218032"/>
<inertia ixx="0.178863713357329" ixy="1.50019641847353E-05" ixz="1.35368730492005E-05" iyy="0.174395775755846" iyz="-9.90771939078091E-06" izz="0.34100152139765"/>
</inertial>
<visual>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="file://${mesh_path}/devices/liquid_transform_xyz/meshes/base_link.STL"/>
</geometry>
<material name="">
<color rgba="0.792156862745098 0.819607843137255 0.933333333333333 1"/>
</material>
</visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="file://${mesh_path}/devices/liquid_transform_xyz/meshes/base_link.STL"/>
</geometry>
</collision>
</link>
<link name="${station_name}${device_name}x_link">
<inertial>
<origin rpy="0 0 0" xyz="0.325214039540178 0.00943452607370124 0.0482611114301988"/>
<mass value="2.10887387421016"/>
<inertia ixx="0.0012305846984949" ixy="5.54649260270946E-07" ixz="3.84099347741331E-07" iyy="0.0349382006090243" iyz="-0.000103697818531446" izz="0.0354178972785773"/>
</inertial>
<visual>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="file://${mesh_path}/devices/liquid_transform_xyz/meshes/x_link.STL"/>
</geometry>
<material name="">
<color rgba="0.792156862745098 0.819607843137255 0.933333333333333 1"/>
</material>
</visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="file://${mesh_path}/devices/liquid_transform_xyz/meshes/x_link.STL"/>
</geometry>
</collision>
</link>
<joint name="${station_name}${device_name}x_joint" type="prismatic">
<origin rpy="0 0 0" xyz="-0.141499999999982 0.334850000000045 0.357700036886815"/>
<parent link="${station_name}${device_name}base_link"/>
<child link="${station_name}${device_name}x_link"/>
<axis xyz="0 1 0"/>
<limit effort="50" lower="-0.3" upper="0" velocity="1"/>
</joint>
<link name="${station_name}${device_name}y_link">
<inertial>
<origin rpy="0 0 0" xyz="-1.50235389641123E-05 -0.00104302241099613 -0.0439486470514941"/>
<mass value="0.57605998885478"/>
<inertia ixx="0.00193021581150653" ixy="3.53777102560584E-08" ixz="2.57202248177777E-07" iyy="0.00224712797067005" iyz="-3.96170906880708E-07" izz="0.000419338880142789"/>
</inertial>
<visual>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="file://${mesh_path}/devices/liquid_transform_xyz/meshes/y_link.STL"/>
</geometry>
<material name="">
<color rgba="0.792156862745098 0.819607843137255 0.933333333333333 1"/>
</material>
</visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="file://${mesh_path}/devices/liquid_transform_xyz/meshes/y_link.STL"/>
</geometry>
</collision>
</link>
<joint name="${station_name}${device_name}y_joint" type="prismatic">
<origin rpy="0 0 0" xyz="0.2855 -0.0330000368868711 0.0578"/>
<parent link="${station_name}${device_name}x_link"/>
<child link="${station_name}${device_name}y_link"/>
<axis xyz="-1 0 0"/>
<limit effort="50" lower="-0.25" upper="0.25" velocity="1"/>
</joint>
<link name="${station_name}${device_name}z_link">
<inertial>
<origin rpy="0 0 0" xyz="-1.07272060046598E-06 -0.00954902784618396 0.017834416924223"/>
<mass value="0.199932032754258"/>
<inertia ixx="0.000219989530768707" ixy="-7.50956522121896E-10" ixz="-1.265045524863E-07" iyy="0.000245054780375167" iyz="-3.76753893185657E-06" izz="4.29092763044732E-05"/>
</inertial>
<visual>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="file://${mesh_path}/devices/liquid_transform_xyz/meshes/z_link.STL"/>
</geometry>
<material name="">
<color rgba="0.792156862745098 0.819607843137255 0.933333333333333 1"/>
</material>
</visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="file://${mesh_path}/devices/liquid_transform_xyz/meshes/z_link.STL"/>
</geometry>
</collision>
</link>
<joint name="${station_name}${device_name}z_joint" type="prismatic">
<origin rpy="0 0 0" xyz="0.000950000000001387 -0.0737000000000002 0"/>
<parent link="${station_name}${device_name}y_link"/>
<child link="${station_name}${device_name}z_link"/>
<axis xyz="0 0 1"/>
<limit effort="50" lower="-0.2" upper="0" velocity="1"/>
</joint>
<link name="${station_name}${device_name}p_link">
</link>
<joint name="${station_name}${device_name}p_joint" type="fixed">
<origin rpy="0 0 0" xyz="0 -0.0139999999999999 -0.10575"/>
<parent link="${station_name}${device_name}z_link"/>
<child link="${station_name}${device_name}p_link"/>
<axis xyz="0 0 0"/>
</joint>
</xacro:macro>
</robot>

View File

@@ -0,0 +1,6 @@
# Balance devices module
# Import balance device modules
from . import mettler_toledo_xpr
__all__ = ['mettler_toledo_xpr']

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
WSDL Template for Mettler Toledo XPR/XSR Balance
IMPORTANT: This is a template file. You need to obtain the actual WSDL file
from Mettler Toledo for your specific balance model.
To use this driver:
1. Contact Mettler Toledo support to obtain the official WSDL file
2. Replace this template with the actual WSDL file
3. Rename it to: MT.Laboratory.Balance.XprXsr.V03.wsdl
The WSDL file contains proprietary information and cannot be distributed
with this open-source project.
-->
<wsdl:definitions xmlns:wsx="http://schemas.xmlsoap.org/ws/2004/09/mex"
xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"
xmlns:wsa10="http://www.w3.org/2005/08/addressing"
xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy"
xmlns:wsap="http://schemas.xmlsoap.org/ws/2004/08/addressing/policy"
xmlns:msc="http://schemas.microsoft.com/ws/2005/12/wsdl/contract"
xmlns:soap12="http://schemas.xmlsoap.org/wsdl/soap12/"
xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"
xmlns:wsam="http://www.w3.org/2007/05/addressing/metadata"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:tns="http://MT/Laboratory/Balance/XprXsr/V03"
xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
xmlns:wsaw="http://www.w3.org/2006/05/addressing/wsdl"
xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/"
targetNamespace="http://MT/Laboratory/Balance/XprXsr/V03"
xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/">
<!--
PLACEHOLDER CONTENT
This template contains only the basic structure.
The actual WSDL file should contain:
- Service definitions
- Port types
- Message definitions
- Binding information
- Endpoint addresses with template variables: {{host}}, {{port}}, {{api_path}}
-->
<wsdl:types>
<!-- Schema definitions will be here in the actual WSDL -->
</wsdl:types>
<!-- Service definitions will be here in the actual WSDL -->
</wsdl:definitions>

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