Compare commits
350 Commits
v0.10.12
...
cf7032fa81
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf7032fa81 | ||
|
|
97681ba433 | ||
|
|
3fa81ab4f6 | ||
|
|
9f4a69ddf5 | ||
|
|
05ae4e72df | ||
|
|
2870c04086 | ||
|
|
343e87df0d | ||
|
|
5d0807cba6 | ||
|
|
4875977d5f | ||
|
|
956b1c905b | ||
|
|
944911c52a | ||
|
|
a13b790926 | ||
|
|
9feadd68c6 | ||
|
|
c68d5246d0 | ||
|
|
49073f2c77 | ||
|
|
b2afc29f15 | ||
|
|
4061280f6b | ||
|
|
6a681e1d73 | ||
|
|
653e6e1ac3 | ||
|
|
2c774bcd1d | ||
|
|
2ba395b681 | ||
|
|
b6b3d59083 | ||
|
|
f40e3f521c | ||
|
|
7cc2fe036f | ||
|
|
f81d20bb1d | ||
|
|
db1b5a869f | ||
|
|
0136630700 | ||
|
|
3c31811f9e | ||
|
|
64f02ff129 | ||
|
|
7d097b8222 | ||
|
|
d266d21104 | ||
|
|
b6d0bbcb17 | ||
|
|
31ebff8e37 | ||
|
|
2132895ba2 | ||
|
|
850eeae55a | ||
|
|
d869c14233 | ||
|
|
24101b3cec | ||
|
|
3bf8aad4d5 | ||
|
|
a599eb70e5 | ||
|
|
0bf6994f95 | ||
|
|
c36f53791c | ||
|
|
eb4d2d96c5 | ||
|
|
8233c41b1d | ||
|
|
0dfd4ce8a8 | ||
|
|
7953b3820e | ||
|
|
eed233fa76 | ||
|
|
0c55147ee4 | ||
|
|
ce6267b8e0 | ||
|
|
975e51cd96 | ||
|
|
c5056b381c | ||
|
|
c35da65b15 | ||
|
|
659cf05be6 | ||
|
|
3b8deb4d1d | ||
|
|
c796615f9f | ||
|
|
a5bad6074f | ||
|
|
1d3a07a736 | ||
|
|
cc2cd57cdf | ||
|
|
39bb7dc627 | ||
|
|
0fda155f55 | ||
|
|
6e3eacd2f0 | ||
|
|
062f1a2153 | ||
|
|
61e8d67800 | ||
|
|
d0884cdbd8 | ||
|
|
545ea45024 | ||
|
|
b9ddee8f2c | ||
|
|
a0c5095304 | ||
|
|
e504505137 | ||
|
|
4d9d5701e9 | ||
|
|
6016c4b588 | ||
|
|
be02bef9c4 | ||
|
|
e62f0c2585 | ||
|
|
b6de0623e2 | ||
|
|
9d081e9fcd | ||
|
|
85a58e3464 | ||
|
|
85590672d8 | ||
|
|
1d4018196d | ||
|
|
5d34f742af | ||
|
|
5bef19e6d6 | ||
|
|
f816799753 | ||
|
|
a45d841769 | ||
|
|
7f0b33b3e3 | ||
|
|
2006406a24 | ||
|
|
f94985632b | ||
|
|
12ba110569 | ||
|
|
97212be8b7 | ||
|
|
9bdd42f12f | ||
|
|
627140da03 | ||
|
|
5ceedb0565 | ||
|
|
8c77a20c43 | ||
|
|
3ff894feee | ||
|
|
fa5896ffdb | ||
|
|
eb504803ac | ||
|
|
8b0c845661 | ||
|
|
693873bfa9 | ||
|
|
57da2d8da2 | ||
|
|
8d1fd01259 | ||
|
|
388259e64b | ||
|
|
2c130e7f37 | ||
|
|
9f7c3f02f9 | ||
|
|
19dd80dcdb | ||
|
|
9d5ed627a2 | ||
|
|
2d0ff87bc8 | ||
|
|
d78475de9a | ||
|
|
88ae56806c | ||
|
|
95dd8beb81 | ||
|
|
4ab3fadbec | ||
|
|
229888f834 | ||
|
|
b443b39ebf | ||
|
|
0434bbc15b | ||
|
|
5791b81954 | ||
|
|
bd51c74fab | ||
|
|
ba81cbddf8 | ||
|
|
4e92a26057 | ||
|
|
c2895bb197 | ||
|
|
0423f4f452 | ||
|
|
41390fbef9 | ||
|
|
98bdb4e7e4 | ||
|
|
30037a077a | ||
|
|
6972680099 | ||
|
|
9d2c93807d | ||
|
|
e728007bc5 | ||
|
|
9c5ecda7cc | ||
|
|
2d26c3fac6 | ||
|
|
f5753afb7c | ||
|
|
398b2dde3f | ||
|
|
62c4135938 | ||
|
|
027b4269c4 | ||
|
|
3757bd9c58 | ||
|
|
c75b7d5aae | ||
|
|
dfc635189c | ||
|
|
d8f3ebac15 | ||
|
|
4a1e703a3a | ||
|
|
55d22a7c29 | ||
|
|
03a4e4ecba | ||
|
|
2316c34cb5 | ||
|
|
a8887161d3 | ||
|
|
25834f5ba0 | ||
|
|
a1e9332b51 | ||
|
|
357fc038ef | ||
|
|
fd58ef07f3 | ||
|
|
93dee2c1dc | ||
|
|
70fbf19009 | ||
|
|
9149155232 | ||
|
|
1ca1792e3c | ||
|
|
485e7e8dd2 | ||
|
|
4ddabdcb65 | ||
|
|
a5b0325301 | ||
|
|
50b44938c7 | ||
|
|
df0d2235b0 | ||
|
|
4e434eeb97 | ||
|
|
ca027bf0eb | ||
|
|
635a332b4e | ||
|
|
edf7a117ca | ||
|
|
70b2715996 | ||
|
|
7e8dfc2dc5 | ||
|
|
9b626489a8 | ||
|
|
03fe208743 | ||
|
|
e913e540a3 | ||
|
|
aed39b648d | ||
|
|
8c8359fab3 | ||
|
|
5d20be0762 | ||
|
|
09f745d300 | ||
|
|
bbcbcde9a4 | ||
|
|
42b437cdea | ||
|
|
ffd0f2d26a | ||
|
|
32422c0b3d | ||
|
|
c44e597dc0 | ||
|
|
4eef012a8e | ||
|
|
ac69452f3c | ||
|
|
57b30f627b | ||
|
|
2d2a4ca067 | ||
|
|
a2613aad4c | ||
|
|
54f75183ff | ||
|
|
735be067dc | ||
|
|
0fe62d64f0 | ||
|
|
2d4ecec1e1 | ||
|
|
0f976a1874 | ||
|
|
b263a7e679 | ||
|
|
7c7f1b31c5 | ||
|
|
00e668e140 | ||
|
|
4989f65a0b | ||
|
|
9fa3688196 | ||
|
|
40fb1ea49c | ||
|
|
18b0bb397e | ||
|
|
65abc5dbf7 | ||
|
|
2455ca15ba | ||
|
|
05a3ff607a | ||
|
|
ec882df36d | ||
|
|
43b992e3eb | ||
|
|
6422fa5a9a | ||
|
|
434b9e98e0 | ||
|
|
040073f430 | ||
|
|
3d95c9896a | ||
|
|
9aa97ed01e | ||
|
|
0b8bdf5e0a | ||
|
|
299f010754 | ||
|
|
15ce0d6883 | ||
|
|
dec474e1a7 | ||
|
|
5f187899fc | ||
|
|
c8d16c7024 | ||
|
|
25d46dc9d5 | ||
|
|
88c4d1a9d1 | ||
|
|
81fd8291c5 | ||
|
|
3a11eb90d4 | ||
|
|
387866b9c9 | ||
|
|
7f40f141f6 | ||
|
|
6fc7ed1b88 | ||
|
|
93f0e08d75 | ||
|
|
4b43734b55 | ||
|
|
174b1914d4 | ||
|
|
704e13f030 | ||
|
|
0c42d60cf2 | ||
|
|
df33e1a214 | ||
|
|
1f49924966 | ||
|
|
609b6006e8 | ||
|
|
67c01271b7 | ||
|
|
a1783f489e | ||
|
|
a8f6527de9 | ||
|
|
54cfaf15f3 | ||
|
|
5610c28b67 | ||
|
|
cfc1ee6e79 | ||
|
|
1c9d2ee98a | ||
|
|
3fe8f4ca44 | ||
|
|
2476821dcc | ||
|
|
7b426ed5ae | ||
|
|
9bbae96447 | ||
|
|
10aabb7592 | ||
|
|
709eb0d91c | ||
|
|
14b7d52825 | ||
|
|
a5397ffe12 | ||
|
|
c6c2da69ba | ||
|
|
622e579063 | ||
|
|
196e0f7e2b | ||
|
|
a632fd495e | ||
|
|
a8cc02a126 | ||
|
|
ad2e1432c6 | ||
|
|
c3b9583eac | ||
|
|
5c47cd0c8a | ||
|
|
63ab1af45d | ||
|
|
a8419dc0c3 | ||
|
|
34f05f2e25 | ||
|
|
0dc2488f02 | ||
|
|
f13156e792 | ||
|
|
13fd1ac572 | ||
|
|
f8ef6e0686 | ||
|
|
94a7b8aaca | ||
|
|
301bea639e | ||
|
|
4b5a83efa4 | ||
|
|
2889e9be2c | ||
|
|
304aebbba7 | ||
|
|
091c9fa247 | ||
|
|
67ca45a240 | ||
|
|
7aab2ea493 | ||
|
|
62f3a6d696 | ||
|
|
eb70ad0e18 | ||
|
|
768f43880e | ||
|
|
762c3c737c | ||
|
|
ace98a4472 | ||
|
|
41eaa88c6f | ||
|
|
a1a55a2c0a | ||
|
|
2eaa0ca729 | ||
|
|
6f8f070f40 | ||
|
|
da4bd927e0 | ||
|
|
01f8816597 | ||
|
|
e5006285df | ||
|
|
573c724a5c | ||
|
|
09549d2839 | ||
|
|
50c7777cea | ||
|
|
4888f02c09 | ||
|
|
779c9693d9 | ||
|
|
ffa841a41a | ||
|
|
fc669f09f8 | ||
|
|
2ca0311de6 | ||
|
|
94cdcbf24e | ||
|
|
1cd07915e7 | ||
|
|
b600fc666d | ||
|
|
9e214c56c1 | ||
|
|
bdf27a7e82 | ||
|
|
2493fb9f94 | ||
|
|
c7a0ff67a9 | ||
|
|
711a7c65fa | ||
|
|
cde7956896 | ||
|
|
95b6fd0451 | ||
|
|
513e848d89 | ||
|
|
58d1cc4720 | ||
|
|
5676dd6589 | ||
|
|
1ae274a833 | ||
|
|
22b88c8441 | ||
|
|
81bcc1907d | ||
|
|
8cffd3dc21 | ||
|
|
a722636938 | ||
|
|
f68340d932 | ||
|
|
361eae2f6d | ||
|
|
c25283ae04 | ||
|
|
961752fb0d | ||
|
|
55165024dd | ||
|
|
6ddceb8393 | ||
|
|
4e52c7d2f4 | ||
|
|
0b56efc89d | ||
|
|
a27b93396a | ||
|
|
2a60a6c27e | ||
|
|
5dda94044d | ||
|
|
0cfc6f45e3 | ||
|
|
831f4549f9 | ||
|
|
f4d4eb06d3 | ||
|
|
e3b8164f6b | ||
|
|
78c04acc2e | ||
|
|
cd0428ea78 | ||
|
|
bdddbd57ba | ||
|
|
a312de08a5 | ||
|
|
68513b5745 | ||
|
|
19027350fb | ||
|
|
bbbdb06bbc | ||
|
|
cd84e26126 | ||
|
|
ce5bab3af1 | ||
|
|
82d9ef6bf7 | ||
|
|
332b33c6f4 | ||
|
|
1ec642ee3a | ||
|
|
7d8e6d029b | ||
|
|
5ec8a57a1f | ||
|
|
ae3c1100ae | ||
|
|
14bc2e6cda | ||
|
|
9f823a4198 | ||
|
|
02c79363c1 | ||
|
|
227ff1284a | ||
|
|
4b7bde6be5 | ||
|
|
8a669ac35a | ||
|
|
a1538da39e | ||
|
|
0063df4cf3 | ||
|
|
e570ba4976 | ||
|
|
e8c1f76dbb | ||
|
|
f791c1a342 | ||
|
|
ea60cbe891 | ||
|
|
eac9b8ab3d | ||
|
|
573bcf1a6c | ||
|
|
50e93cb1af | ||
|
|
fe1a029a9b | ||
|
|
662c063f50 | ||
|
|
01cbbba0b3 | ||
|
|
e6c556cf19 | ||
|
|
0605f305ed | ||
|
|
37d8108ec4 | ||
|
|
6081dac561 | ||
|
|
5b2d066127 | ||
|
|
06e66765e7 | ||
|
|
98ce360088 | ||
|
|
5cd0f72fbd | ||
|
|
343f394203 | ||
|
|
46aa7a7bd2 | ||
|
|
a66369e2c3 |
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.4
|
||||
version: 0.10.11
|
||||
|
||||
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
|
||||
|
||||
342
.github/workflows/conda-pack-build.yml
vendored
@@ -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' || 'bash' }}
|
||||
|
||||
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,99 +63,201 @@ 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-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 -n unilab 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 -n unilab 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 -n unilab ros-humble-unilabos-msgs
|
||||
for /f "tokens=2" %%i in ('conda list -n unilab 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 -n unilab 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 -n unilab 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 -n unilab 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...
|
||||
mamba run -n unilab pip uninstall unilabos -y || echo unilabos not installed via pip
|
||||
echo Installing unilabos from source (branch: ${{ github.event.inputs.branch }})...
|
||||
mamba run -n unilab pip install .
|
||||
echo Verifying installation...
|
||||
mamba run -n unilab 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"
|
||||
|
||||
mamba run -n unilab pip uninstall unilabos -y || echo "unilabos not installed via pip"
|
||||
echo "Installing unilabos from source (branch: ${{ github.event.inputs.branch }})..."
|
||||
pip install .
|
||||
|
||||
mamba run -n unilab pip install .
|
||||
echo "Verifying installation..."
|
||||
pip show unilabos
|
||||
mamba run -n unilab 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 ===
|
||||
mamba env list
|
||||
echo.
|
||||
echo === Installed Packages ===
|
||||
mamba list -n unilab | findstr /C:"unilabos" /C:"ros-humble-unilabos-msgs" || mamba list -n unilab
|
||||
echo.
|
||||
echo === Python Packages ===
|
||||
mamba run -n unilab pip list | findstr unilabos || mamba run -n unilab 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
|
||||
mamba env list
|
||||
echo ""
|
||||
echo "=== Installed Packages ==="
|
||||
conda list | grep -E "(unilabos|ros-humble-unilabos-msgs)" || conda list
|
||||
mamba list -n unilab | grep -E "(unilabos|ros-humble-unilabos-msgs)" || mamba list -n unilab
|
||||
echo ""
|
||||
echo "=== Python Packages ==="
|
||||
pip list | grep unilabos || pip list
|
||||
mamba run -n unilab pip list | grep unilabos || mamba run -n unilab 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...
|
||||
mamba run -n unilab python -c "import sys; print(f'Python version: {sys.version}')"
|
||||
echo Verifying unilabos import...
|
||||
mamba run -n unilab python -c "import unilabos; print(f'UniLabOS version: {unilabos.__version__}')" || echo Warning: Could not import unilabos
|
||||
echo Checking critical packages...
|
||||
mamba run -n unilab python -c "import rclpy; print('ROS2 rclpy: OK')"
|
||||
echo Running comprehensive verification script...
|
||||
mamba run -n unilab python scripts\verify_installation.py --auto-install || 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}')"
|
||||
|
||||
mamba run -n unilab 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"
|
||||
|
||||
mamba run -n unilab 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')"
|
||||
|
||||
mamba run -n unilab python -c "import rclpy; print('ROS2 rclpy: OK')"
|
||||
echo "Running comprehensive verification script..."
|
||||
python scripts/verify_installation.py || echo "Warning: Verification script reported issues"
|
||||
|
||||
mamba run -n unilab python scripts/verify_installation.py --auto-install || 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...
|
||||
mamba activate unilab && 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..."
|
||||
mamba install conda-pack -c conda-forge -y
|
||||
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 Copy source code repository (including .git)
|
||||
echo Adding: Uni-Lab-OS source repository
|
||||
robocopy . dist-package\Uni-Lab-OS /E /XD dist-package /NFL /NDL /NJH /NJS /NC /NS || if %ERRORLEVEL% LSS 8 exit /b 0
|
||||
|
||||
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,115 +269,59 @@ 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
|
||||
# Copy source code repository (including .git)
|
||||
echo "Adding: Uni-Lab-OS source repository"
|
||||
rsync -a --exclude='dist-package' . dist-package/Uni-Lab-OS
|
||||
|
||||
# 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'
|
||||
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 "=========================================="
|
||||
|
||||
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 ""
|
||||
|
||||
- name: Upload distribution package
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
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 +330,11 @@ 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 "Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}"
|
||||
echo ""
|
||||
echo "Download the artifact and run the install script!"
|
||||
echo "After download:"
|
||||
echo " install_unilab.sh"
|
||||
echo "=========================================="
|
||||
|
||||
43
.github/workflows/deploy-docs.yml
vendored
@@ -39,24 +39,39 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python environment
|
||||
uses: actions/setup-python@v5
|
||||
- name: Setup Miniforge (with mamba)
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
python-version: '3.10'
|
||||
miniforge-version: latest
|
||||
use-mamba: true
|
||||
python-version: '3.11.11'
|
||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||
channel-priority: flexible
|
||||
activate-environment: unilab
|
||||
auto-update-conda: false
|
||||
show-channel-urls: true
|
||||
|
||||
- name: Install system dependencies
|
||||
- name: Install unilabos and dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pandoc
|
||||
echo "Installing unilabos and dependencies to unilab environment..."
|
||||
echo "Using mamba for faster and more reliable dependency resolution..."
|
||||
mamba install -n unilab uni-lab::unilabos -c uni-lab -c robostack-staging -c conda-forge -y
|
||||
|
||||
- name: Install Python dependencies
|
||||
- name: Install latest unilabos from source
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Install package in development mode to get version info
|
||||
pip install -e .
|
||||
# Install documentation dependencies
|
||||
pip install -r docs/requirements.txt
|
||||
echo "Uninstalling existing unilabos..."
|
||||
mamba run -n unilab pip uninstall unilabos -y || echo "unilabos not installed via pip"
|
||||
echo "Installing unilabos from source..."
|
||||
mamba run -n unilab pip install .
|
||||
echo "Verifying installation..."
|
||||
mamba run -n unilab pip show unilabos
|
||||
|
||||
- name: Install documentation dependencies
|
||||
run: |
|
||||
echo "Installing documentation build dependencies..."
|
||||
mamba run -n unilab pip install -r docs/requirements.txt
|
||||
|
||||
- name: Setup Pages
|
||||
id: pages
|
||||
@@ -68,8 +83,8 @@ jobs:
|
||||
cd docs
|
||||
# Clean previous builds
|
||||
rm -rf _build
|
||||
# Build HTML documentation
|
||||
python -m sphinx -b html . _build/html -v
|
||||
# Build HTML documentation in conda environment
|
||||
mamba run -n unilab python -m sphinx -b html . _build/html -v
|
||||
|
||||
- name: Check build results
|
||||
run: |
|
||||
|
||||
3
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
|
||||
18
CONTRIBUTORS
Normal file
@@ -0,0 +1,18 @@
|
||||
56 Xuwznln <18435084+Xuwznln@users.noreply.github.com>
|
||||
10 wznln <18435084+Xuwznln@users.noreply.github.com>
|
||||
6 Junhan Chang <changjh@dp.tech>
|
||||
5 ZiWei <131428629+ZiWei09@users.noreply.github.com>
|
||||
2 Guangxin Zhang <guangxin.zhang.bio@gmail.com>
|
||||
2 Junhan Chang <changjh@pku.edu.cn>
|
||||
2 WenzheG <wenzheguo32@gmail.com>
|
||||
1 Harry Liu <113173203+ALITTLELZ@users.noreply.github.com>
|
||||
1 Harvey Que <103566763+Mile-Away@users.noreply.github.com>
|
||||
1 Junhan Chang <1700011741@pku.edu.cn>
|
||||
1 Xianwei Qi <qxw@stu.pku.edu.cn>
|
||||
1 hh.(SII) <103566763+Mile-Away@users.noreply.github.com>
|
||||
1 lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com>
|
||||
1 q434343 <73513873+q434343@users.noreply.github.com>
|
||||
1 tt <166512503+tt11142023@users.noreply.github.com>
|
||||
1 xyc <49015816+xiaoyu10031@users.noreply.github.com>
|
||||
1 王俊杰 <1800011822@pku.edu.cn>
|
||||
1 王俊杰 <43375851+wjjxxx@users.noreply.github.com>
|
||||
@@ -1,3 +1,4 @@
|
||||
recursive-include unilabos/test *
|
||||
recursive-include unilabos/registry *.yaml
|
||||
recursive-include unilabos/app/web/static *
|
||||
recursive-include unilabos/app/web/templates *
|
||||
|
||||
@@ -31,7 +31,7 @@ Join the [Intelligent Organic Chemistry Synthesis Competition](https://bohrium.d
|
||||
|
||||
Detailed documentation can be found at:
|
||||
|
||||
- [Online Documentation](https://dptech-corp.github.io/Uni-Lab-OS/)
|
||||
- [Online Documentation](https://xuwznln.github.io/Uni-Lab-OS-Doc/)
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -55,7 +55,7 @@ pip install .
|
||||
|
||||
3. Start Uni-Lab System:
|
||||
|
||||
Please refer to [Documentation - Boot Examples](https://dptech-corp.github.io/Uni-Lab-OS/boot_examples/index.html)
|
||||
Please refer to [Documentation - Boot Examples](https://xuwznln.github.io/Uni-Lab-OS-Doc/boot_examples/index.html)
|
||||
|
||||
## Message Format
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控
|
||||
|
||||
详细文档可在以下位置找到:
|
||||
|
||||
- [在线文档](https://dptech-corp.github.io/Uni-Lab-OS/)
|
||||
- [在线文档](https://xuwznln.github.io/Uni-Lab-OS-Doc/)
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -57,7 +57,7 @@ pip install .
|
||||
|
||||
3. 启动 Uni-Lab 系统:
|
||||
|
||||
请见[文档-启动样例](https://dptech-corp.github.io/Uni-Lab-OS/boot_examples/index.html)
|
||||
请见[文档-启动样例](https://xuwznln.github.io/Uni-Lab-OS-Doc/boot_examples/index.html)
|
||||
|
||||
## 消息格式
|
||||
|
||||
|
||||
726
docs/advanced_usage/configuration.md
Normal file
@@ -0,0 +1,726 @@
|
||||
# Uni-Lab 配置指南
|
||||
|
||||
本文档详细介绍 Uni-Lab 配置文件的结构、配置项、命令行覆盖和环境变量的使用方法。
|
||||
|
||||
## 配置文件概述
|
||||
|
||||
Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_data/local_config.py`。配置文件采用类属性的方式定义各种配置项,比 YAML 或 JSON 提供更多的灵活性,包括支持注释、条件逻辑和复杂数据结构。
|
||||
|
||||
## 获取实验室密钥
|
||||
|
||||
在配置文件或启动命令中,您需要提供实验室的访问密钥(ak)和私钥(sk)。
|
||||
|
||||
**获取方式:**
|
||||
|
||||
进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk:
|
||||
|
||||

|
||||
|
||||
## 配置文件格式
|
||||
|
||||
### 默认配置示例
|
||||
|
||||
首次使用时,系统会自动创建一个基础配置文件 `local_config.py`:
|
||||
|
||||
```python
|
||||
# unilabos的配置文件
|
||||
|
||||
class BasicConfig:
|
||||
ak = "" # 实验室网页给您提供的ak代码
|
||||
sk = "" # 实验室网页给您提供的sk代码
|
||||
|
||||
|
||||
# WebSocket配置,一般无需调整
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
```
|
||||
|
||||
### 完整配置示例
|
||||
|
||||
您可以根据需要添加更多配置选项:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
"""Uni-Lab 配置文件"""
|
||||
|
||||
# 基础配置
|
||||
class BasicConfig:
|
||||
ak = "" # 实验室访问密钥
|
||||
sk = "" # 实验室私钥
|
||||
working_dir = "" # 工作目录(通常自动设置)
|
||||
config_path = "" # 配置文件路径(自动设置)
|
||||
is_host_mode = True # 是否为主站模式
|
||||
slave_no_host = False # 从站模式下是否跳过等待主机服务
|
||||
upload_registry = False # 是否上传注册表
|
||||
machine_name = "undefined" # 机器名称(自动获取)
|
||||
vis_2d_enable = False # 是否启用2D可视化
|
||||
enable_resource_load = True # 是否启用资源加载
|
||||
communication_protocol = "websocket" # 通信协议
|
||||
log_level = "DEBUG" # 日志级别:TRACE, DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
|
||||
# WebSocket配置
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
|
||||
# HTTP配置
|
||||
class HTTPConfig:
|
||||
remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址
|
||||
|
||||
# ROS配置
|
||||
class ROSConfig:
|
||||
modules = [
|
||||
"std_msgs.msg",
|
||||
"geometry_msgs.msg",
|
||||
"control_msgs.msg",
|
||||
"control_msgs.action",
|
||||
"nav2_msgs.action",
|
||||
"unilabos_msgs.msg",
|
||||
"unilabos_msgs.action",
|
||||
] # 需要加载的ROS模块
|
||||
```
|
||||
|
||||
## 配置优先级
|
||||
|
||||
配置项的生效优先级从高到低为:
|
||||
|
||||
1. **命令行参数**:最高优先级
|
||||
2. **环境变量**:中等优先级
|
||||
3. **配置文件**:基础优先级
|
||||
|
||||
这意味着命令行参数会覆盖环境变量和配置文件,环境变量会覆盖配置文件。
|
||||
|
||||
## 推荐配置方式
|
||||
|
||||
根据参数特性,不同配置项有不同的推荐配置方式:
|
||||
|
||||
### 建议通过命令行指定的参数(不需要写入配置文件)
|
||||
|
||||
以下参数推荐通过命令行或环境变量指定,**一般不需要在配置文件中配置**:
|
||||
|
||||
| 参数 | 命令行参数 | 原因 |
|
||||
| ----------------- | ------------------- | ------------------------------------ |
|
||||
| `ak` / `sk` | `--ak` / `--sk` | **安全考虑**:避免敏感信息泄露 |
|
||||
| `working_dir` | `--working_dir` | **灵活性**:不同环境可能使用不同目录 |
|
||||
| `is_host_mode` | `--is_slave` | **运行模式**:由启动场景决定,不固定 |
|
||||
| `slave_no_host` | `--slave_no_host` | **运行模式**:从站特殊配置,按需使用 |
|
||||
| `upload_registry` | `--upload_registry` | **临时操作**:仅首次启动或更新时需要 |
|
||||
| `vis_2d_enable` | `--2d_vis` | **调试功能**:按需临时启用 |
|
||||
| `remote_addr` | `--addr` | **环境切换**:测试/生产环境快速切换 |
|
||||
|
||||
**推荐用法示例:**
|
||||
|
||||
```bash
|
||||
# 标准启动命令(所有必要参数通过命令行指定)
|
||||
unilab --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 测试环境
|
||||
unilab --addr test --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 从站模式
|
||||
unilab --is_slave --ak your_ak --sk your_sk
|
||||
|
||||
# 首次启动上传注册表
|
||||
unilab --ak your_ak --sk your_sk -g graph.json --upload_registry
|
||||
```
|
||||
|
||||
### 适合在配置文件中配置的参数
|
||||
|
||||
以下参数适合在配置文件中配置,通常不会频繁更改:
|
||||
|
||||
| 参数 | 配置类 | 说明 |
|
||||
| ------------------------ | ----------- | ---------------------- |
|
||||
| `log_level` | BasicConfig | 日志级别配置 |
|
||||
| `reconnect_interval` | WSConfig | WebSocket 重连间隔 |
|
||||
| `max_reconnect_attempts` | WSConfig | WebSocket 最大重连次数 |
|
||||
| `ping_interval` | WSConfig | WebSocket 心跳间隔 |
|
||||
| `modules` | ROSConfig | ROS 模块列表 |
|
||||
|
||||
**配置文件示例(推荐最小配置):**
|
||||
|
||||
```python
|
||||
# unilabos的配置文件
|
||||
|
||||
class BasicConfig:
|
||||
log_level = "INFO" # 生产环境建议 INFO,调试时用 DEBUG
|
||||
|
||||
# WebSocket配置,一般保持默认即可
|
||||
class WSConfig:
|
||||
reconnect_interval = 5
|
||||
max_reconnect_attempts = 999
|
||||
ping_interval = 30
|
||||
```
|
||||
|
||||
**注意:** `ak` 和 `sk` 不建议写在配置文件中,始终通过命令行参数或环境变量传递。
|
||||
|
||||
## 命令行参数覆盖配置
|
||||
|
||||
Uni-Lab 允许通过命令行参数覆盖配置文件中的设置,提供更灵活的配置方式。
|
||||
|
||||
### 支持命令行覆盖的配置项
|
||||
|
||||
| 配置类 | 配置字段 | 命令行参数 | 说明 |
|
||||
| ------------- | ----------------- | ------------------- | -------------------------------- |
|
||||
| `BasicConfig` | `ak` | `--ak` | 实验室访问密钥 |
|
||||
| `BasicConfig` | `sk` | `--sk` | 实验室私钥 |
|
||||
| `BasicConfig` | `working_dir` | `--working_dir` | 工作目录路径 |
|
||||
| `BasicConfig` | `is_host_mode` | `--is_slave` | 主站模式(参数为从站模式,取反) |
|
||||
| `BasicConfig` | `slave_no_host` | `--slave_no_host` | 从站模式下跳过等待主机服务 |
|
||||
| `BasicConfig` | `upload_registry` | `--upload_registry` | 启动时上传注册表信息 |
|
||||
| `BasicConfig` | `vis_2d_enable` | `--2d_vis` | 启用 2D 可视化 |
|
||||
| `HTTPConfig` | `remote_addr` | `--addr` | 远程服务地址 |
|
||||
|
||||
### 特殊命令行参数
|
||||
|
||||
除了直接覆盖配置项的参数外,还有一些特殊的命令行参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
| ------------------- | ------------------------------------ |
|
||||
| `--config` | 指定配置文件路径 |
|
||||
| `--port` | Web 服务端口(不影响配置文件) |
|
||||
| `--disable_browser` | 禁用自动打开浏览器(不影响配置文件) |
|
||||
| `--visual` | 可视化工具选择(不影响配置文件) |
|
||||
| `--skip_env_check` | 跳过环境检查(不影响配置文件) |
|
||||
|
||||
### 命令行覆盖使用示例
|
||||
|
||||
```bash
|
||||
# 通过命令行覆盖认证信息
|
||||
unilab --ak "new_access_key" --sk "new_secret_key" -g graph.json
|
||||
|
||||
# 覆盖服务器地址
|
||||
unilab --ak ak --sk sk --addr "https://custom.server.com/api/v1" -g graph.json
|
||||
|
||||
# 启用从站模式并跳过等待主机
|
||||
unilab --is_slave --slave_no_host --ak ak --sk sk
|
||||
|
||||
# 启用上传注册表和2D可视化
|
||||
unilab --upload_registry --2d_vis --ak ak --sk sk -g graph.json
|
||||
|
||||
# 组合使用多个覆盖参数
|
||||
unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g graph.json
|
||||
```
|
||||
|
||||
### 预设环境地址
|
||||
|
||||
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL:
|
||||
|
||||
- `test` → `https://uni-lab.test.bohrium.com/api/v1`
|
||||
- `uat` → `https://uni-lab.uat.bohrium.com/api/v1`
|
||||
- `local` → `http://127.0.0.1:48197/api/v1`
|
||||
- 其他值 → 直接使用作为完整 URL
|
||||
|
||||
## 配置选项详解
|
||||
|
||||
### 1. BasicConfig - 基础配置
|
||||
|
||||
基础配置包含了系统运行的核心参数:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------------------ | ---- | ------------- | ------------------------------------------ |
|
||||
| `ak` | str | `""` | 实验室访问密钥(必需) |
|
||||
| `sk` | str | `""` | 实验室私钥(必需) |
|
||||
| `working_dir` | str | `""` | 工作目录,通常自动设置 |
|
||||
| `config_path` | str | `""` | 配置文件路径,自动设置 |
|
||||
| `is_host_mode` | bool | `True` | 是否为主站模式 |
|
||||
| `slave_no_host` | bool | `False` | 从站模式下是否跳过等待主机服务 |
|
||||
| `upload_registry` | bool | `False` | 启动时是否上传注册表信息 |
|
||||
| `machine_name` | str | `"undefined"` | 机器名称,自动从 hostname 获取(不可配置) |
|
||||
| `vis_2d_enable` | bool | `False` | 是否启用 2D 可视化 |
|
||||
| `enable_resource_load` | bool | `True` | 是否启用资源加载 |
|
||||
| `communication_protocol` | str | `"websocket"` | 通信协议,固定为 websocket |
|
||||
| `log_level` | str | `"DEBUG"` | 日志级别 |
|
||||
|
||||
#### 日志级别选项
|
||||
|
||||
- `TRACE` - 追踪级别(最详细)
|
||||
- `DEBUG` - 调试级别(默认)
|
||||
- `INFO` - 信息级别
|
||||
- `WARNING` - 警告级别
|
||||
- `ERROR` - 错误级别
|
||||
- `CRITICAL` - 严重错误级别(最简略)
|
||||
|
||||
#### 认证配置(ak / sk)
|
||||
|
||||
`ak` 和 `sk` 是必需的认证参数:
|
||||
|
||||
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
|
||||
2. **配置方式**:
|
||||
- **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
|
||||
- **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK`
|
||||
- **配置文件**:在 `BasicConfig` 类中设置(不推荐,安全风险)
|
||||
3. **安全注意**:请妥善保管您的密钥信息,不要提交到版本控制
|
||||
|
||||
**推荐做法**:
|
||||
|
||||
- **开发环境**:使用命令行参数或环境变量
|
||||
- **生产环境**:使用环境变量
|
||||
- **临时测试**:使用命令行参数
|
||||
|
||||
### 2. WSConfig - WebSocket 配置
|
||||
|
||||
WebSocket 是 Uni-Lab 的主要通信方式:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------------------ | ---- | ------ | ------------------ |
|
||||
| `reconnect_interval` | int | `5` | 断线重连间隔(秒) |
|
||||
| `max_reconnect_attempts` | int | `999` | 最大重连次数 |
|
||||
| `ping_interval` | int | `30` | 心跳检测间隔(秒) |
|
||||
|
||||
### 3. HTTPConfig - HTTP 配置
|
||||
|
||||
HTTP 客户端配置用于与云端服务通信:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------- | ---- | -------------------------------------- | ------------ |
|
||||
| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 |
|
||||
|
||||
**预设环境地址**:
|
||||
|
||||
- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认)
|
||||
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
|
||||
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
|
||||
- 本地环境:`http://127.0.0.1:48197/api/v1`
|
||||
|
||||
### 4. ROSConfig - ROS 配置
|
||||
|
||||
配置 ROS 消息转换器需要加载的模块:
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 说明 |
|
||||
| --------- | ---- | ---------- | ------------ |
|
||||
| `modules` | list | 见下方示例 | ROS 模块列表 |
|
||||
|
||||
**默认模块列表:**
|
||||
|
||||
```python
|
||||
class ROSConfig:
|
||||
modules = [
|
||||
"std_msgs.msg", # 标准消息类型
|
||||
"geometry_msgs.msg", # 几何消息类型
|
||||
"control_msgs.msg", # 控制消息类型
|
||||
"control_msgs.action", # 控制动作类型
|
||||
"nav2_msgs.action", # 导航动作类型
|
||||
"unilabos_msgs.msg", # UniLab 自定义消息类型
|
||||
"unilabos_msgs.action", # UniLab 自定义动作类型
|
||||
]
|
||||
```
|
||||
|
||||
您可以根据实际使用的设备和功能添加其他 ROS 模块。
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
Uni-Lab 支持通过环境变量覆盖配置文件中的设置。
|
||||
|
||||
### 环境变量命名规则
|
||||
|
||||
```
|
||||
UNILABOS_<配置类名>_<配置项名>
|
||||
```
|
||||
|
||||
**注意:**
|
||||
|
||||
- 环境变量名不区分大小写
|
||||
- 配置类名和配置项名都会转换为大写进行匹配
|
||||
|
||||
### 设置环境变量
|
||||
|
||||
#### Linux / macOS
|
||||
|
||||
```bash
|
||||
# 临时设置(当前终端)
|
||||
export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO
|
||||
export UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
export UNILABOS_BASICCONFIG_SK="your_secret_key"
|
||||
|
||||
# 永久设置(添加到 ~/.bashrc 或 ~/.zshrc)
|
||||
echo 'export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
#### Windows (cmd)
|
||||
|
||||
```cmd
|
||||
# 临时设置
|
||||
set UNILABOS_BASICCONFIG_LOG_LEVEL=INFO
|
||||
set UNILABOS_BASICCONFIG_AK=your_access_key
|
||||
|
||||
# 永久设置(系统环境变量)
|
||||
setx UNILABOS_BASICCONFIG_LOG_LEVEL INFO
|
||||
```
|
||||
|
||||
#### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
# 临时设置
|
||||
$env:UNILABOS_BASICCONFIG_LOG_LEVEL="INFO"
|
||||
$env:UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
|
||||
# 永久设置
|
||||
[Environment]::SetEnvironmentVariable("UNILABOS_BASICCONFIG_LOG_LEVEL", "INFO", "User")
|
||||
```
|
||||
|
||||
### 环境变量类型转换
|
||||
|
||||
系统会根据配置项的原始类型自动转换环境变量值:
|
||||
|
||||
| 原始类型 | 转换规则 |
|
||||
| -------- | --------------------------------------- |
|
||||
| `bool` | "true", "1", "yes" → True;其他 → False |
|
||||
| `int` | 转换为整数 |
|
||||
| `float` | 转换为浮点数 |
|
||||
| `str` | 直接使用字符串值 |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
# 布尔值
|
||||
export UNILABOS_BASICCONFIG_IS_HOST_MODE=true # 将设置为 True
|
||||
export UNILABOS_BASICCONFIG_IS_HOST_MODE=false # 将设置为 False
|
||||
|
||||
# 整数
|
||||
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL=10 # 将设置为 10
|
||||
|
||||
# 字符串
|
||||
export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO # 将设置为 "INFO"
|
||||
```
|
||||
|
||||
### 环境变量示例
|
||||
|
||||
```bash
|
||||
# 设置基础配置
|
||||
export UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
export UNILABOS_BASICCONFIG_SK="your_secret_key"
|
||||
export UNILABOS_BASICCONFIG_IS_HOST_MODE="true"
|
||||
|
||||
# 设置WebSocket配置
|
||||
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
|
||||
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
|
||||
|
||||
# 设置HTTP配置
|
||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1"
|
||||
```
|
||||
|
||||
## 配置文件使用方法
|
||||
|
||||
### 1. 使用默认配置文件(推荐)
|
||||
|
||||
系统会自动查找并加载配置文件:
|
||||
|
||||
```bash
|
||||
# 直接启动,使用默认的 unilabos_data/local_config.py
|
||||
unilab --ak your_ak --sk your_sk -g graph.json
|
||||
```
|
||||
|
||||
查找顺序:
|
||||
|
||||
1. 环境变量 `UNILABOS_BASICCONFIG_CONFIG_PATH` 指定的路径
|
||||
2. 工作目录下的 `local_config.py`
|
||||
3. 首次使用时会引导创建配置文件
|
||||
|
||||
### 2. 指定配置文件启动
|
||||
|
||||
```bash
|
||||
# 使用指定配置文件启动
|
||||
unilab --config /path/to/your/config.py --ak ak --sk sk -g graph.json
|
||||
```
|
||||
|
||||
### 3. 配置文件验证
|
||||
|
||||
系统启动时会自动验证配置文件:
|
||||
|
||||
- **语法检查**:确保 Python 语法正确
|
||||
- **类型检查**:验证配置项类型是否匹配
|
||||
- **加载确认**:控制台输出加载成功信息
|
||||
|
||||
## 常用配置场景
|
||||
|
||||
### 场景 1:调整日志级别
|
||||
|
||||
**配置文件方式:**
|
||||
|
||||
```python
|
||||
class BasicConfig:
|
||||
log_level = "INFO" # 生产环境建议使用 INFO 或 WARNING
|
||||
```
|
||||
|
||||
**环境变量方式:**
|
||||
|
||||
```bash
|
||||
export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO
|
||||
unilab --ak ak --sk sk -g graph.json
|
||||
```
|
||||
|
||||
**命令行方式**(需要配置文件已包含):
|
||||
|
||||
```bash
|
||||
# 配置文件无直接命令行参数,需通过环境变量
|
||||
UNILABOS_BASICCONFIG_LOG_LEVEL=INFO unilab --ak ak --sk sk -g graph.json
|
||||
```
|
||||
|
||||
### 场景 2:配置 WebSocket 重连
|
||||
|
||||
**配置文件方式:**
|
||||
|
||||
```python
|
||||
class WSConfig:
|
||||
reconnect_interval = 10 # 增加重连间隔到 10 秒
|
||||
max_reconnect_attempts = 100 # 减少最大重连次数到 100 次
|
||||
```
|
||||
|
||||
**环境变量方式:**
|
||||
|
||||
```bash
|
||||
export UNILABOS_WSCONFIG_RECONNECT_INTERVAL=10
|
||||
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100
|
||||
```
|
||||
|
||||
### 场景 3:切换服务器环境
|
||||
|
||||
**配置文件方式:**
|
||||
|
||||
```python
|
||||
class HTTPConfig:
|
||||
remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
||||
```
|
||||
|
||||
**环境变量方式:**
|
||||
|
||||
```bash
|
||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1
|
||||
```
|
||||
|
||||
**命令行方式(推荐):**
|
||||
|
||||
```bash
|
||||
unilab --addr test --ak your_ak --sk your_sk -g graph.json
|
||||
```
|
||||
|
||||
### 场景 4:从站模式配置
|
||||
|
||||
**配置文件方式:**
|
||||
|
||||
```python
|
||||
class BasicConfig:
|
||||
is_host_mode = False # 从站模式
|
||||
slave_no_host = True # 不等待主机服务
|
||||
```
|
||||
|
||||
**命令行方式(推荐):**
|
||||
|
||||
```bash
|
||||
unilab --is_slave --slave_no_host --ak your_ak --sk your_sk
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 安全配置
|
||||
|
||||
**不要在配置文件中存储敏感信息**
|
||||
|
||||
- ❌ **不推荐**:在配置文件中明文存储 ak/sk
|
||||
- ✅ **推荐**:使用环境变量或命令行参数
|
||||
|
||||
```bash
|
||||
# 生产环境 - 使用环境变量(推荐)
|
||||
export UNILABOS_BASICCONFIG_AK="your_access_key"
|
||||
export UNILABOS_BASICCONFIG_SK="your_secret_key"
|
||||
unilab -g graph.json
|
||||
|
||||
# 或使用命令行参数
|
||||
unilab --ak "your_access_key" --sk "your_secret_key" -g graph.json
|
||||
```
|
||||
|
||||
**其他安全建议:**
|
||||
|
||||
- 不要将包含密钥的配置文件提交到版本控制系统
|
||||
- 限制配置文件权限:`chmod 600 local_config.py`
|
||||
- 定期更换访问密钥
|
||||
- 使用 `.gitignore` 排除配置文件
|
||||
|
||||
### 2. 多环境配置
|
||||
|
||||
为不同环境创建不同的配置文件:
|
||||
|
||||
```
|
||||
configs/
|
||||
├── base_config.py # 基础配置(非敏感)
|
||||
├── dev_config.py # 开发环境
|
||||
├── test_config.py # 测试环境
|
||||
├── prod_config.py # 生产环境
|
||||
└── example_config.py # 示例配置
|
||||
```
|
||||
|
||||
**环境切换示例**:
|
||||
|
||||
```bash
|
||||
# 本地开发环境
|
||||
unilab --config configs/dev_config.py --addr local --ak ak --sk sk -g graph.json
|
||||
|
||||
# 测试环境
|
||||
unilab --config configs/test_config.py --addr test --ak ak --sk sk --upload_registry -g graph.json
|
||||
|
||||
# 生产环境
|
||||
unilab --config configs/prod_config.py --ak "$PROD_AK" --sk "$PROD_SK" -g graph.json
|
||||
```
|
||||
|
||||
### 3. 配置管理
|
||||
|
||||
**配置文件最佳实践:**
|
||||
|
||||
- 保持配置文件简洁,只包含需要修改的配置项
|
||||
- 为配置项添加注释说明其作用
|
||||
- 定期检查和更新配置文件
|
||||
- 版本控制仅保存示例配置,不包含实际密钥
|
||||
|
||||
**命令行参数优先使用场景:**
|
||||
|
||||
- 临时测试不同配置
|
||||
- CI/CD 流水线中的动态配置
|
||||
- 不同环境间快速切换
|
||||
- 敏感信息的安全传递
|
||||
|
||||
### 4. 灵活配置策略
|
||||
|
||||
**基础配置文件 + 命令行覆盖**的推荐方式:
|
||||
|
||||
```python
|
||||
# base_config.py - 基础配置(非敏感信息)
|
||||
class BasicConfig:
|
||||
# 非敏感配置写在文件中
|
||||
is_host_mode = True
|
||||
upload_registry = False
|
||||
vis_2d_enable = False
|
||||
log_level = "INFO"
|
||||
|
||||
class WSConfig:
|
||||
reconnect_interval = 5
|
||||
max_reconnect_attempts = 999
|
||||
ping_interval = 30
|
||||
```
|
||||
|
||||
```bash
|
||||
# 启动时通过命令行覆盖关键参数
|
||||
unilab --config base_config.py \
|
||||
--ak "$AK" \
|
||||
--sk "$SK" \
|
||||
--addr "test" \
|
||||
--upload_registry \
|
||||
--2d_vis \
|
||||
-g graph.json
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 1. 配置文件加载失败
|
||||
|
||||
**错误信息**:`[ENV] 配置文件 xxx 不存在`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认配置文件路径正确
|
||||
- 检查文件权限是否可读
|
||||
- 确保配置文件是 `.py` 格式
|
||||
- 使用绝对路径或相对于当前目录的路径
|
||||
|
||||
### 2. 语法错误
|
||||
|
||||
**错误信息**:`[ENV] 加载配置文件 xxx 失败`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 检查 Python 语法是否正确
|
||||
- 确认类名和字段名拼写正确
|
||||
- 验证缩进是否正确(使用空格而非制表符)
|
||||
- 确保字符串使用引号包裹
|
||||
|
||||
### 3. 认证失败
|
||||
|
||||
**错误信息**:`后续运行必须拥有一个实验室`
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认 `ak` 和 `sk` 已正确配置
|
||||
- 检查密钥是否有效(未过期或撤销)
|
||||
- 确认网络连接正常
|
||||
- 验证密钥是否来自正确的实验室
|
||||
|
||||
### 4. 环境变量不生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认环境变量名格式正确(`UNILABOS_<类名>_<字段名>`)
|
||||
- 检查环境变量是否已正确设置(`echo $VARIABLE_NAME`)
|
||||
- 重启终端或重新加载环境变量
|
||||
- 确认环境变量值的类型正确
|
||||
|
||||
### 5. 命令行参数不生效
|
||||
|
||||
**错误现象**:设置了命令行参数但配置没有生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 确认参数名拼写正确(如 `--ak` 而不是 `--access_key`)
|
||||
- 检查参数格式是否正确(布尔参数如 `--is_slave` 不需要值)
|
||||
- 确认参数位置正确(所有参数都应在 `unilab` 之后)
|
||||
- 查看启动日志确认参数是否被正确解析
|
||||
- 检查是否有配置文件或环境变量与之冲突
|
||||
|
||||
### 6. 配置优先级混淆
|
||||
|
||||
**错误现象**:不确定哪个配置生效
|
||||
|
||||
**解决方法**:
|
||||
|
||||
- 记住优先级:**命令行参数 > 环境变量 > 配置文件**
|
||||
- 使用 `--ak` 和 `--sk` 参数时会看到提示信息:"传入了 ak 参数,优先采用传入参数!"
|
||||
- 检查启动日志中的配置加载信息
|
||||
- 临时移除低优先级配置来测试高优先级配置是否生效
|
||||
- 使用 `printenv | grep UNILABOS` 查看所有相关环境变量
|
||||
|
||||
## 配置验证
|
||||
|
||||
### 检查配置是否生效
|
||||
|
||||
启动 Uni-Lab 时,控制台会输出配置加载信息:
|
||||
|
||||
```
|
||||
[ENV] 配置文件 /path/to/config.py 加载成功
|
||||
[ENV] 设置 BasicConfig.log_level = INFO
|
||||
传入了ak参数,优先采用传入参数!
|
||||
传入了sk参数,优先采用传入参数!
|
||||
```
|
||||
|
||||
### 常见配置错误
|
||||
|
||||
1. **配置文件格式错误**
|
||||
|
||||
```
|
||||
[ENV] 加载配置文件 /path/to/config.py 失败
|
||||
```
|
||||
|
||||
**解决方案**:检查 Python 语法,确保配置类定义正确
|
||||
|
||||
2. **环境变量格式错误**
|
||||
|
||||
```
|
||||
[ENV] 环境变量格式不正确:UNILABOS_INVALID_VAR
|
||||
```
|
||||
|
||||
**解决方案**:确保环境变量遵循 `UNILABOS_<类名>_<字段名>` 格式
|
||||
|
||||
3. **类或字段不存在**
|
||||
```
|
||||
[ENV] 未找到类:UNKNOWNCONFIG
|
||||
[ENV] 类 BasicConfig 中未找到字段:UNKNOWN_FIELD
|
||||
```
|
||||
**解决方案**:检查配置类名和字段名是否正确
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [工作目录详解](working_directory.md)
|
||||
- [启动参数详解](../user_guide/launch.md)
|
||||
- [快速安装指南](../user_guide/quick_install_guide.md)
|
||||
BIN
docs/advanced_usage/image/copy_aksk.gif
Normal file
|
After Width: | Height: | Size: 526 KiB |
218
docs/advanced_usage/working_directory.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# 工作目录详解
|
||||
|
||||
本文档详细介绍 Uni-Lab 工作目录(`working_dir`)的判断逻辑和详细用法。
|
||||
|
||||
## 什么是工作目录
|
||||
|
||||
工作目录是 Uni-Lab 存储配置文件、日志和运行数据的目录。默认情况下,工作目录为 `当前目录/unilabos_data`。
|
||||
|
||||
## 工作目录判断逻辑
|
||||
|
||||
系统按以下决策树自动确定工作目录:
|
||||
|
||||
### 第一步:初始判断
|
||||
|
||||
```python
|
||||
# 检查当前目录
|
||||
if 当前目录以 "unilabos_data" 结尾:
|
||||
working_dir = 当前目录的绝对路径
|
||||
else:
|
||||
working_dir = 当前目录/unilabos_data
|
||||
```
|
||||
|
||||
**解释:**
|
||||
- 如果您已经在 `unilabos_data` 目录内启动,系统直接使用当前目录
|
||||
- 否则,系统会在当前目录下创建或使用 `unilabos_data` 子目录
|
||||
|
||||
### 第二步:处理 `--working_dir` 参数
|
||||
|
||||
如果用户指定了 `--working_dir` 参数:
|
||||
|
||||
```python
|
||||
working_dir = 用户指定的路径
|
||||
```
|
||||
|
||||
此时还会检查配置文件:
|
||||
- 如果同时指定了 `--config` 但该文件不存在
|
||||
- 系统会尝试在 `working_dir/local_config.py` 查找
|
||||
- 如果仍未找到,报错退出
|
||||
|
||||
### 第三步:处理 `--config` 参数
|
||||
|
||||
如果用户指定了 `--config` 且文件存在:
|
||||
|
||||
```python
|
||||
# 工作目录改为配置文件所在目录
|
||||
working_dir = config_path 的父目录
|
||||
```
|
||||
|
||||
**重要:** 这意味着配置文件的位置会影响工作目录的判断。
|
||||
|
||||
## 使用场景示例
|
||||
|
||||
### 场景 1:默认场景(推荐)
|
||||
|
||||
```bash
|
||||
# 当前目录:/home/user/project
|
||||
unilab --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 结果:
|
||||
# working_dir = /home/user/project/unilabos_data
|
||||
# config_path = /home/user/project/unilabos_data/local_config.py
|
||||
```
|
||||
|
||||
### 场景 2:在 unilabos_data 目录内启动
|
||||
|
||||
```bash
|
||||
cd /home/user/project/unilabos_data
|
||||
unilab --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 结果:
|
||||
# working_dir = /home/user/project/unilabos_data
|
||||
# config_path = /home/user/project/unilabos_data/local_config.py
|
||||
```
|
||||
|
||||
### 场景 3:手动指定工作目录
|
||||
|
||||
```bash
|
||||
unilab --working_dir /custom/path --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 结果:
|
||||
# working_dir = /custom/path
|
||||
# config_path = /custom/path/local_config.py (如果存在)
|
||||
```
|
||||
|
||||
### 场景 4:通过配置文件路径推断工作目录
|
||||
|
||||
```bash
|
||||
unilab --config /data/lab_a/local_config.py --ak your_ak --sk your_sk -g graph.json
|
||||
|
||||
# 结果:
|
||||
# working_dir = /data/lab_a
|
||||
# config_path = /data/lab_a/local_config.py
|
||||
```
|
||||
|
||||
## 高级用法:管理多个实验室配置
|
||||
|
||||
### 方法 1:使用不同的工作目录
|
||||
|
||||
```bash
|
||||
# 实验室 A
|
||||
unilab --working_dir ~/labs/lab_a --ak ak_a --sk sk_a -g graph_a.json
|
||||
|
||||
# 实验室 B
|
||||
unilab --working_dir ~/labs/lab_b --ak ak_b --sk sk_b -g graph_b.json
|
||||
```
|
||||
|
||||
### 方法 2:使用不同的配置文件
|
||||
|
||||
```bash
|
||||
# 实验室 A
|
||||
unilab --config ~/labs/lab_a/config.py --ak ak_a --sk sk_a -g graph_a.json
|
||||
|
||||
# 实验室 B
|
||||
unilab --config ~/labs/lab_b/config.py --ak ak_b --sk sk_b -g graph_b.json
|
||||
```
|
||||
|
||||
### 方法 3:使用shell脚本管理
|
||||
|
||||
创建 `start_lab_a.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
cd ~/labs/lab_a
|
||||
unilab --ak your_ak_a --sk your_sk_a -g graph_a.json
|
||||
```
|
||||
|
||||
创建 `start_lab_b.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
cd ~/labs/lab_b
|
||||
unilab --ak your_ak_b --sk your_sk_b -g graph_b.json
|
||||
```
|
||||
|
||||
## 完整决策流程图
|
||||
|
||||
```
|
||||
开始
|
||||
↓
|
||||
判断当前目录是否以 unilabos_data 结尾?
|
||||
├─ 是 → working_dir = 当前目录
|
||||
└─ 否 → working_dir = 当前目录/unilabos_data
|
||||
↓
|
||||
用户是否指定 --working_dir?
|
||||
└─ 是 → working_dir = 指定路径
|
||||
↓
|
||||
用户是否指定 --config 且文件存在?
|
||||
└─ 是 → working_dir = config 文件所在目录
|
||||
↓
|
||||
检查 working_dir/local_config.py 是否存在?
|
||||
├─ 是 → 加载配置文件 → 继续启动
|
||||
└─ 否 → 询问是否首次使用
|
||||
├─ 是 → 创建目录和配置文件 → 继续启动
|
||||
└─ 否 → 退出程序
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 如何查看当前使用的工作目录?
|
||||
|
||||
启动 Uni-Lab 时,系统会在控制台输出:
|
||||
|
||||
```
|
||||
当前工作目录为 /path/to/working_dir
|
||||
```
|
||||
|
||||
### 2. 可以在同一台机器上运行多个实验室吗?
|
||||
|
||||
可以。使用不同的工作目录或配置文件即可:
|
||||
|
||||
```bash
|
||||
# 终端 1
|
||||
unilab --working_dir ~/lab1 --ak ak1 --sk sk1 -g graph1.json
|
||||
|
||||
# 终端 2
|
||||
unilab --working_dir ~/lab2 --ak ak2 --sk sk2 -g graph2.json
|
||||
```
|
||||
|
||||
### 3. 工作目录中存储了什么?
|
||||
|
||||
- `local_config.py` - 配置文件
|
||||
- 日志文件
|
||||
- 临时运行数据
|
||||
- 缓存文件
|
||||
|
||||
### 4. 可以删除工作目录吗?
|
||||
|
||||
可以,但会丢失:
|
||||
- 配置文件(需要重新创建)
|
||||
- 历史日志
|
||||
- 缓存数据
|
||||
|
||||
建议定期备份配置文件。
|
||||
|
||||
### 5. 如何迁移到新的工作目录?
|
||||
|
||||
```bash
|
||||
# 1. 复制旧的工作目录
|
||||
cp -r ~/old_path/unilabos_data ~/new_path/unilabos_data
|
||||
|
||||
# 2. 在新位置启动
|
||||
cd ~/new_path
|
||||
unilab --ak your_ak --sk your_sk -g graph.json
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用默认工作目录**:对于单一实验室,使用默认的 `./unilabos_data` 即可
|
||||
2. **组织多实验室**:为每个实验室创建独立的目录结构
|
||||
3. **版本控制**:将配置文件纳入版本控制,但排除日志和缓存
|
||||
4. **备份配置**:定期备份 `local_config.py` 文件
|
||||
5. **使用脚本**:为不同实验室创建启动脚本,简化操作
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [配置文件指南](configuration.md)
|
||||
- [启动参数详解](../user_guide/launch.md)
|
||||
|
||||
@@ -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 接口进行调用,以实现复杂的移液流程。
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
使用以下命令启动模拟反应器:
|
||||
|
||||
```bash
|
||||
unilab -g test/experiments/mock_reactor.json --app_bridges ""
|
||||
unilab -g test/experiments/mock_reactor.json
|
||||
```
|
||||
|
||||
### 2. 执行抽真空和充气操作
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
(instructions)=
|
||||
# 设备抽象、指令集与通信中间件
|
||||
|
||||
Uni-Lab 操作系统的目的是将不同类型和厂家的实验仪器进行抽象统一,对应用层提供服务。因此,理清实验室设备之间的业务逻辑至关重要。
|
||||
Uni-Lab-OS的目的是将不同类型和厂家的实验仪器进行抽象统一,对应用层提供服务。因此,理清实验室设备之间的业务逻辑至关重要。
|
||||
|
||||
## 设备间通信模式
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Uni-Lab 的组态图当前支持 node-link json 和 graphml 格式,其中包
|
||||
对用户来说,“直接操作设备执行单个指令”不是个真实需求,真正的需求是**“执行对实验有意义的单个完整动作”——加入某种液体多少量;萃取分液;洗涤仪器等等。就像实验步骤文字书写的那样。**
|
||||
|
||||
而这些对实验有意义的单个完整动作,**一般需要多个设备的协同**,还依赖于他们的**物理连接关系(管道相连;机械臂可转运)**。
|
||||
于是 Uni-Lab 实现了抽象的“工作站”,即注册表中的 `workstation` 设备(`ProtocolNode`类)来处理编译、规划操作。以泵骨架组成的自动有机实验室为例,设备管道连接关系如下:
|
||||
于是 Uni-Lab 实现了抽象的“工作站”,即注册表中的 `workstation` 设备(`WorkstationNode`类)来处理编译、规划操作。以泵骨架组成的自动有机实验室为例,设备管道连接关系如下:
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ extensions = [
|
||||
"myst_parser",
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings
|
||||
"sphinx_rtd_theme"
|
||||
"sphinx_rtd_theme",
|
||||
"sphinxcontrib.mermaid"
|
||||
]
|
||||
|
||||
source_suffix = {
|
||||
@@ -42,6 +43,8 @@ myst_enable_extensions = [
|
||||
"substitution",
|
||||
]
|
||||
|
||||
myst_fence_as_directive = ["mermaid"]
|
||||
|
||||
templates_path = ["_templates"]
|
||||
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
||||
|
||||
@@ -203,3 +206,5 @@ def generate_action_includes(app):
|
||||
|
||||
def setup(app):
|
||||
app.connect("builder-inited", generate_action_includes)
|
||||
app.add_js_file("https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js")
|
||||
app.add_js_file(None, body="mermaid.initialize({startOnLoad:true});")
|
||||
|
||||
@@ -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 request,Uni-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" 部分,每个平台都有对应的构建包可供下载。
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# 设备 Driver 开发
|
||||
# 设备 Driver 开发(无 SDK 设备)
|
||||
|
||||
我们对设备 Driver 的定义,是一个 Python/C++/C# 类,类的方法可以用于获取传感器数据、执行设备动作、更新物料信息。它们经过 Uni-Lab 的通信中间件包装,就能成为高效分布式通信的设备节点。
|
||||
|
||||
因此,若已有设备的 SDK (Driver),可以直接 [添加进 Uni-Lab](add_device.md)。仅当没有 SDK (Driver) 时,请参考本章作开发。
|
||||
因此,若已有设备的 SDK (Driver),可以直接 [添加进 Uni-Lab](add_device.md)。**仅当没有 SDK (Driver) 时,请参考本章进行驱动开发。**
|
||||
|
||||
> **💡 提示:** 本文档介绍如何为没有现成驱动的老设备开发驱动程序。如果您的设备已经有 SDK 或驱动,请直接参考 {doc}`add_device`。
|
||||
|
||||
## 有串口字符串指令集文档的设备:Python 串口通信(常见 RS485, RS232, USB)
|
||||
|
||||
@@ -12,13 +14,13 @@
|
||||
|
||||
Modbus 与 RS485、RS232 不一样的地方在于,会有更多直接寄存器的读写,以及涉及字节序转换(Big Endian, Little Endian)。
|
||||
|
||||
Uni-Lab 开发团队在仓库中提供了3个样例:
|
||||
Uni-Lab 开发团队在仓库中提供了 3 个样例:
|
||||
|
||||
* 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
|
||||
* 单一通信设备**IO板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
|
||||
* 执行多设备复杂任务逻辑的**PLC**,Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
|
||||
- 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
|
||||
- 单一通信设备**IO 板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
|
||||
- 执行多设备复杂任务逻辑的**PLC**,Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
|
||||
|
||||
****
|
||||
---
|
||||
|
||||
## 其他工业通信协议:CANopen, Ethernet, OPCUA...
|
||||
|
||||
@@ -26,32 +28,32 @@ Uni-Lab 开发团队在仓库中提供了3个样例:
|
||||
|
||||
## 没有接口的老设备老软件:使用 PyWinAuto
|
||||
|
||||
**pywinauto**是一个 Python 库,用于自动化Windows GUI操作。它可以模拟用户的鼠标点击、键盘输入、窗口操作等,广泛应用于自动化测试、GUI自动化等场景。它支持通过两个后端进行操作:
|
||||
**pywinauto**是一个 Python 库,用于自动化 Windows GUI 操作。它可以模拟用户的鼠标点击、键盘输入、窗口操作等,广泛应用于自动化测试、GUI 自动化等场景。它支持通过两个后端进行操作:
|
||||
|
||||
* **win32**后端:适用于大多数Windows应用程序,使用native Win32 API。(pywinauto_recorder默认使用win32后端)
|
||||
* **uia**后端:基于Microsoft UI Automation,适用于较新的应用程序,特别是基于WPF或UWP的应用程序。(在win10上,会有更全的目录,有的窗口win32会识别不到)
|
||||
- **win32**后端:适用于大多数 Windows 应用程序,使用 native Win32 API。(pywinauto_recorder 默认使用 win32 后端)
|
||||
- **uia**后端:基于 Microsoft UI Automation,适用于较新的应用程序,特别是基于 WPF 或 UWP 的应用程序。(在 win10 上,会有更全的目录,有的窗口 win32 会识别不到)
|
||||
|
||||
### windows平台安装pywinauto和pywinauto_recorder
|
||||
### windows 平台安装 pywinauto 和 pywinauto_recorder
|
||||
|
||||
直接安装会造成环境崩溃,需要下载并解压已经修改好的文件。
|
||||
|
||||
cd到对应目录,执行安装
|
||||
cd 到对应目录,执行安装
|
||||
|
||||
`pip install . -i ``https://pypi.tuna.tsinghua.edu.cn/simple`
|
||||
` pip install . -i ``https://pypi.tuna.tsinghua.edu.cn/simple `
|
||||
|
||||

|
||||
|
||||
windows平台测试 python pywinauto_recorder.py,退出使用两次ctrl+alt+r取消选中,关闭命令提示符。
|
||||
windows 平台测试 python pywinauto_recorder.py,退出使用两次 ctrl+alt+r 取消选中,关闭命令提示符。
|
||||
|
||||
### 计算器例子
|
||||
|
||||
你可以先打开windows的计算器,然后在ilab的环境中运行下面的代码片段,可观察到得到结果,通过这一案例,你需要掌握的pywinauto用法:
|
||||
你可以先打开 windows 的计算器,然后在 ilab 的环境中运行下面的代码片段,可观察到得到结果,通过这一案例,你需要掌握的 pywinauto 用法:
|
||||
|
||||
* 连接到指定进程
|
||||
* 利用dump_tree查找需要的窗口
|
||||
* 获取某个位置的信息
|
||||
* 模拟点击
|
||||
* 模拟输入
|
||||
- 连接到指定进程
|
||||
- 利用 dump_tree 查找需要的窗口
|
||||
- 获取某个位置的信息
|
||||
- 模拟点击
|
||||
- 模拟输入
|
||||
|
||||
#### 代码学习
|
||||
|
||||
@@ -74,39 +76,39 @@ window.dump_tree(depth=3)
|
||||
Dialog - '计算器' (L-419, T773, R-73, B1287)
|
||||
['计算器Dialog', 'Dialog', '计算器', '计算器Dialog0', '计算器Dialog1', 'Dialog0', 'Dialog1', '计算器0', '计算器1']
|
||||
child_window(title="计算器", control_type="Window")
|
||||
|
|
||||
|
|
||||
| Dialog - '计算器' (L-269, T774, R-81, B806)
|
||||
| ['计算器Dialog2', 'Dialog2', '计算器2']
|
||||
| child_window(title="计算器", auto_id="TitleBar", control_type="Window")
|
||||
| |
|
||||
| |
|
||||
| | Menu - '系统' (L0, T0, R0, B0)
|
||||
| | ['Menu', '系统', '系统Menu', '系统0', '系统1']
|
||||
| | child_window(title="系统", auto_id="SystemMenuBar", control_type="MenuBar")
|
||||
| |
|
||||
| |
|
||||
| | Button - '最小化 计算器' (L-219, T774, R-173, B806)
|
||||
| | ['Button', '最小化 计算器Button', '最小化 计算器', 'Button0', 'Button1']
|
||||
| | child_window(title="最小化 计算器", auto_id="Minimize", control_type="Button")
|
||||
| |
|
||||
| |
|
||||
| | Button - '使 计算器 最大化' (L-173, T774, R-127, B806)
|
||||
| | ['Button2', '使 计算器 最大化', '使 计算器 最大化Button']
|
||||
| | child_window(title="使 计算器 最大化", auto_id="Maximize", control_type="Button")
|
||||
| |
|
||||
| |
|
||||
| | Button - '关闭 计算器' (L-127, T774, R-81, B806)
|
||||
| | ['Button3', '关闭 计算器Button', '关闭 计算器']
|
||||
| | child_window(title="关闭 计算器", auto_id="Close", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Dialog - '计算器' (L-411, T774, R-81, B1279)
|
||||
| ['计算器Dialog3', 'Dialog3', '计算器3']
|
||||
| child_window(title="计算器", control_type="Window")
|
||||
| |
|
||||
| |
|
||||
| | Static - '计算器' (L-363, T782, R-327, B798)
|
||||
| | ['计算器Static', 'Static', '计算器4', 'Static0', 'Static1']
|
||||
| | child_window(title="计算器", auto_id="AppName", control_type="Text")
|
||||
| |
|
||||
| |
|
||||
| | Custom - '' (L-411, T806, R-81, B1279)
|
||||
| | ['Custom', '计算器Custom']
|
||||
| | child_window(auto_id="NavView", control_type="Custom")
|
||||
|
|
||||
|
|
||||
| Pane - '' (L-411, T806, R-81, B1279)
|
||||
| ['Pane', '计算器Pane']
|
||||
"""
|
||||
@@ -122,58 +124,58 @@ target_window.dump_tree(depth=3)
|
||||
Custom - '' (L-411, T806, R-81, B1279)
|
||||
['标准Custom', 'Custom']
|
||||
child_window(auto_id="NavView", control_type="Custom")
|
||||
|
|
||||
|
|
||||
| Button - '打开导航' (L-407, T812, R-367, B848)
|
||||
| ['打开导航Button', '打开导航', 'Button', 'Button0', 'Button1']
|
||||
| child_window(title="打开导航", auto_id="TogglePaneButton", control_type="Button")
|
||||
| |
|
||||
| |
|
||||
| | Static - '' (L0, T0, R0, B0)
|
||||
| | ['Static', 'Static0', 'Static1']
|
||||
| | child_window(auto_id="PaneTitleTextBlock", control_type="Text")
|
||||
|
|
||||
|
|
||||
| GroupBox - '' (L-411, T814, R-81, B1275)
|
||||
| ['标准GroupBox', 'GroupBox', 'GroupBox0', 'GroupBox1']
|
||||
| |
|
||||
| |
|
||||
| | Static - '表达式为 ' (L0, T0, R0, B0)
|
||||
| | ['表达式为 ', 'Static2', '表达式为 Static']
|
||||
| | child_window(title="表达式为 ", auto_id="CalculatorExpression", control_type="Text")
|
||||
| |
|
||||
| |
|
||||
| | Static - '显示为 0' (L-411, T875, R-81, B947)
|
||||
| | ['显示为 0Static', '显示为 0', 'Static3']
|
||||
| | child_window(title="显示为 0", auto_id="CalculatorResults", control_type="Text")
|
||||
| |
|
||||
| |
|
||||
| | Button - '打开历史记录浮出控件' (L-121, T814, R-89, B846)
|
||||
| | ['打开历史记录浮出控件', '打开历史记录浮出控件Button', 'Button2']
|
||||
| | child_window(title="打开历史记录浮出控件", auto_id="HistoryButton", control_type="Button")
|
||||
| |
|
||||
| |
|
||||
| | GroupBox - '记忆控件' (L-407, T948, R-85, B976)
|
||||
| | ['记忆控件', '记忆控件GroupBox', 'GroupBox2']
|
||||
| | child_window(title="记忆控件", auto_id="MemoryPanel", control_type="Group")
|
||||
| |
|
||||
| |
|
||||
| | GroupBox - '显示控件' (L-407, T978, R-85, B1026)
|
||||
| | ['显示控件', 'GroupBox3', '显示控件GroupBox']
|
||||
| | child_window(title="显示控件", auto_id="DisplayControls", control_type="Group")
|
||||
| |
|
||||
| |
|
||||
| | GroupBox - '标准函数' (L-407, T1028, R-166, B1076)
|
||||
| | ['标准函数', '标准函数GroupBox', 'GroupBox4']
|
||||
| | child_window(title="标准函数", auto_id="StandardFunctions", control_type="Group")
|
||||
| |
|
||||
| |
|
||||
| | GroupBox - '标准运算符' (L-164, T1028, R-85, B1275)
|
||||
| | ['标准运算符', '标准运算符GroupBox', 'GroupBox5']
|
||||
| | child_window(title="标准运算符", auto_id="StandardOperators", control_type="Group")
|
||||
| |
|
||||
| |
|
||||
| | GroupBox - '数字键盘' (L-407, T1078, R-166, B1275)
|
||||
| | ['GroupBox6', '数字键盘', '数字键盘GroupBox']
|
||||
| | child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
||||
| |
|
||||
| |
|
||||
| | Button - '正负' (L-407, T1228, R-328, B1275)
|
||||
| | ['Button32', '正负Button', '正负']
|
||||
| | child_window(title="正负", auto_id="negateButton", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Static - '标准' (L-363, T815, R-322, B842)
|
||||
| ['标准', '标准Static', 'Static4']
|
||||
| child_window(title="标准", auto_id="Header", control_type="Text")
|
||||
|
|
||||
|
|
||||
| Button - '始终置顶' (L-312, T814, R-280, B846)
|
||||
| ['始终置顶Button', '始终置顶', 'Button33']
|
||||
| child_window(title="始终置顶", auto_id="NormalAlwaysOnTopButton", control_type="Button")
|
||||
@@ -187,47 +189,47 @@ numpad.dump_tree(depth=2)
|
||||
GroupBox - '数字键盘' (L-334, T1350, R-93, B1547)
|
||||
['GroupBox', '数字键盘', '数字键盘GroupBox']
|
||||
child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
||||
|
|
||||
|
|
||||
| Button - '零' (L-253, T1500, R-174, B1547)
|
||||
| ['零Button', 'Button', '零', 'Button0', 'Button1']
|
||||
| child_window(title="零", auto_id="num0Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '一' (L-334, T1450, R-255, B1498)
|
||||
| ['一Button', 'Button2', '一']
|
||||
| child_window(title="一", auto_id="num1Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '二' (L-253, T1450, R-174, B1498)
|
||||
| ['Button3', '二', '二Button']
|
||||
| child_window(title="二", auto_id="num2Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '三' (L-172, T1450, R-93, B1498)
|
||||
| ['Button4', '三', '三Button']
|
||||
| child_window(title="三", auto_id="num3Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '四' (L-334, T1400, R-255, B1448)
|
||||
| ['四', 'Button5', '四Button']
|
||||
| child_window(title="四", auto_id="num4Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '五' (L-253, T1400, R-174, B1448)
|
||||
| ['Button6', '五Button', '五']
|
||||
| child_window(title="五", auto_id="num5Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '六' (L-172, T1400, R-93, B1448)
|
||||
| ['六Button', 'Button7', '六']
|
||||
| child_window(title="六", auto_id="num6Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '七' (L-334, T1350, R-255, B1398)
|
||||
| ['Button8', '七Button', '七']
|
||||
| child_window(title="七", auto_id="num7Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '八' (L-253, T1350, R-174, B1398)
|
||||
| ['八', 'Button9', '八Button']
|
||||
| child_window(title="八", auto_id="num8Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '九' (L-172, T1350, R-93, B1398)
|
||||
| ['Button10', '九', '九Button']
|
||||
| child_window(title="九", auto_id="num9Button", control_type="Button")
|
||||
|
|
||||
|
|
||||
| Button - '十进制分隔符' (L-172, T1500, R-93, B1547)
|
||||
| ['十进制分隔符Button', 'Button11', '十进制分隔符']
|
||||
| child_window(title="十进制分隔符", auto_id="decimalSeparatorButton", control_type="Button")
|
||||
@@ -262,13 +264,13 @@ r, g, b = pyautogui.pixel(point_x, point_y)
|
||||
|
||||
### pywinauto_recorder
|
||||
|
||||
pywinauto_recorder是一个配合 pywinauto 使用的工具,用于录制用户的操作,并生成相应的 pywinauto 脚本。这对于一些暂时无法直接调用DLL的函数并且需要模拟用户操作的场景非常有用。同时,可以省去仅用pywinauto的一些查找UI步骤。
|
||||
pywinauto_recorder 是一个配合 pywinauto 使用的工具,用于录制用户的操作,并生成相应的 pywinauto 脚本。这对于一些暂时无法直接调用 DLL 的函数并且需要模拟用户操作的场景非常有用。同时,可以省去仅用 pywinauto 的一些查找 UI 步骤。
|
||||
|
||||
#### 运行尝试
|
||||
|
||||
请参照 上手尝试-环境创建-3 开启pywinauto_recorder
|
||||
请参照 上手尝试-环境创建-3 开启 pywinauto_recorder
|
||||
|
||||
例如我们这里先启动一个windows自带的计算器软件
|
||||
例如我们这里先启动一个 windows 自带的计算器软件
|
||||
|
||||

|
||||
|
||||
@@ -286,7 +288,7 @@ with UIPath(u"计算器||Window"):
|
||||
click(u"九||Button")
|
||||
```
|
||||
|
||||
执行该python脚本,可以观察到新开启的计算器被点击了数字9
|
||||
执行该 python 脚本,可以观察到新开启的计算器被点击了数字 9
|
||||
|
||||

|
||||
|
||||
@@ -308,23 +310,38 @@ window.dump_tree(depth=[int类型数字], filename=None)
|
||||
GroupBox - '数字键盘' (L-334, T1350, R-93, B1547)
|
||||
['GroupBox', '数字键盘', '数字键盘GroupBox']
|
||||
child_window(title="数字键盘", auto_id="NumberPad", control_type="Group")
|
||||
|
|
||||
|
|
||||
| Button - '零' (L-253, T1500, R-174, B1547)
|
||||
| ['零Button', 'Button', '零', 'Button0', 'Button1']
|
||||
| child_window(title="零", auto_id="num0Button", control_type="Button")
|
||||
"""
|
||||
```
|
||||
|
||||
这里以上面计算器的例子对dump_tree进行解读
|
||||
这里以上面计算器的例子对 dump_tree 进行解读
|
||||
|
||||
2~4行为当前对象的窗口
|
||||
2~4 行为当前对象的窗口
|
||||
|
||||
* 第2行分别是窗体的类型 `GroupBox`,窗体的题目 `数字键盘`,窗体的矩形区域坐标,对应的是屏幕上的位置(左、上、右、下)
|
||||
* 第3行是 `['GroupBox', '数字键盘', '数字键盘GroupBox']`,为控件的标识符列表,可以选择任意一个,使用 `child_window(best_match="标识符")`来获取该窗口
|
||||
* 第4行是获取该控件的方法,请注意该方法不能保证获取唯一,`title`如果是变化的,也需要删除 `title`参数
|
||||
- 第 2 行分别是窗体的类型 `GroupBox`,窗体的题目 `数字键盘`,窗体的矩形区域坐标,对应的是屏幕上的位置(左、上、右、下)
|
||||
- 第 3 行是 `['GroupBox', '数字键盘', '数字键盘GroupBox']`,为控件的标识符列表,可以选择任意一个,使用 `child_window(best_match="标识符")`来获取该窗口
|
||||
- 第 4 行是获取该控件的方法,请注意该方法不能保证获取唯一,`title`如果是变化的,也需要删除 `title`参数
|
||||
|
||||
6~8行为当前对象窗口所包含的子窗口信息,信息类型对应2~4行
|
||||
6~8 行为当前对象窗口所包含的子窗口信息,信息类型对应 2~4 行
|
||||
|
||||
### 窗口获取注意事项
|
||||
|
||||
1. 在 `child_window`的时候,并不会立刻报错,只有在执行窗口的信息获取时才会调用,查询窗口是否存在,因此要想确定 `child_window`是否正确,可以调用子窗口对象的属性 `element_info`,来保证窗口存在
|
||||
1. 在 `child_window`的时候,并不会立刻报错,只有在执行窗口的信息获取时才会调用,查询窗口是否存在,因此要想确定 `child_window`是否正确,可以调用子窗口对象的属性 `element_info`,来保证窗口存在
|
||||
|
||||
---
|
||||
|
||||
## 下一步
|
||||
|
||||
完成设备驱动开发后,建议继续阅读:
|
||||
|
||||
- {doc}`add_device` - 了解如何将驱动添加到 Uni-Lab 中
|
||||
- {doc}`add_action` - 学习如何添加新的动作指令
|
||||
- {doc}`add_yaml` - 编写和完善 YAML 注册表
|
||||
|
||||
进阶主题:
|
||||
|
||||
- {doc}`03_add_device_registry` - 详细的注册表配置
|
||||
- {doc}`04_add_device_testing` - 设备测试指南
|
||||
1139
docs/developer_guide/add_registry.md
Normal 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. **监控指标**:设置关键性能指标的监控和告警
|
||||
|
||||
202
docs/developer_guide/examples/battery_plc_workstation.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# 实例:电池装配工站接入(PLC 控制)
|
||||
|
||||
> **文档类型**:实际应用案例
|
||||
> **适用场景**:使用 PLC 控制的电池装配工站接入
|
||||
> **前置知识**:{doc}`../add_device` | {doc}`../add_registry`
|
||||
|
||||
本指南以电池装配工站为实际案例,引导你完成 PLC 控制设备的完整接入流程,包括新建工站文件、编写驱动与寄存器读写、生成注册表、上传及注意事项。
|
||||
|
||||
## 案例概述
|
||||
|
||||
**设备类型**:电池装配工站
|
||||
**通信方式**:Modbus TCP (PLC)
|
||||
**工站基类**:`WorkstationBase`
|
||||
**主要功能**:电池组装、寄存器读写、数据采集
|
||||
|
||||
## 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>
|
||||
```
|
||||
|
||||
点击注册表编辑,进入注册表编辑页面
|
||||
|
||||

|
||||
|
||||
按照图示步骤填写自动生成注册表信息:
|
||||
|
||||

|
||||
|
||||
步骤说明:
|
||||
|
||||
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. 生成注册表
|
||||
|
||||
以上操作步骤完成,则会生成的新的注册表 YAML 文件,如下图:
|
||||
|
||||

|
||||
|
||||
### 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. 注意事项
|
||||
|
||||
### 4.1 验证模块路径
|
||||
|
||||
在新生成的 YAML 中,确认 `module` 指向新工站类。本例中需检查 `coincellassemblyworkstation_device.yaml` 文件中是否正确指向了 `CoinCellAssemblyWorkstation` 类:
|
||||
|
||||
```yaml
|
||||
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
|
||||
```
|
||||
|
||||
### 4.2 首次接入流程
|
||||
|
||||
首次新增设备(或资源)需要完整流程:
|
||||
|
||||
1. ✅ 在网页端生成注册表信息
|
||||
2. ✅ 使用 `--complete_registry` 补全注册表
|
||||
3. ✅ 使用 `--upload_registry` 上传注册表信息
|
||||
|
||||
### 4.3 驱动更新流程
|
||||
|
||||
如果不是新增设备,仅修改了工站驱动的 `.py` 文件:
|
||||
|
||||
1. ✅ 运行 `--complete_registry` 补全注册表
|
||||
2. ✅ 运行 `--upload_registry` 上传注册表
|
||||
3. ❌ 不需要在网页端重新生成注册表
|
||||
|
||||
### 4.4 PLC 通信注意事项
|
||||
|
||||
- **握手机制**:若需参数下发,建议在 PLC 端设置标志寄存器并完成握手复位,避免粘连与竞争
|
||||
- **字节序**:FLOAT32 等多字节数据类型需要正确指定字节序(如 `WorderOrder.LITTLE`)
|
||||
- **寄存器映射**:确保 CSV 文件中的寄存器地址与 PLC 实际配置一致
|
||||
- **连接稳定性**:在初始化时检查 PLC 连接状态,建议添加重连机制
|
||||
|
||||
## 5. 扩展阅读
|
||||
|
||||
### 相关文档
|
||||
|
||||
- {doc}`../add_device` - 设备驱动编写通用指南
|
||||
- {doc}`../add_registry` - 注册表配置完整指南
|
||||
- {doc}`../workstation_architecture` - 工站架构详解
|
||||
|
||||
### 技术要点
|
||||
|
||||
- **Modbus TCP 通信**:PLC 通信协议和寄存器读写
|
||||
- **WorkstationBase**:工站基类的继承和使用
|
||||
- **寄存器映射**:CSV 格式的寄存器配置
|
||||
- **注册表生成**:自动化工具使用
|
||||
|
||||
## 6. 总结
|
||||
|
||||
通过本案例,你应该掌握:
|
||||
|
||||
1. ✅ 如何创建 PLC 控制的工站驱动
|
||||
2. ✅ Modbus TCP 通信和寄存器读写
|
||||
3. ✅ 使用可视化编辑器生成注册表
|
||||
4. ✅ 注册表的补全和上传流程
|
||||
5. ✅ 新增设备与更新驱动的区别
|
||||
|
||||
这个案例展示了完整的 PLC 设备接入流程,可以作为其他类似设备接入的参考模板。
|
||||
|
After Width: | Height: | Size: 428 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 66 KiB |
409
docs/developer_guide/examples/materials_construction_guide.md
Normal file
@@ -0,0 +1,409 @@
|
||||
# 实例:物料构建指南
|
||||
|
||||
> **文档类型**:物料系统实战指南
|
||||
> **适用场景**:工作站物料系统构建、Deck/Warehouse/Carrier/Bottle 配置
|
||||
> **前置知识**:PyLabRobot 基础 | 资源管理概念
|
||||
|
||||
## 概述
|
||||
|
||||
在UniLab-OS系统中,任何工作站中所需要用到的物料主要包括四个核心组件:
|
||||
|
||||
1. **桌子(Deck)** - 工作台面,定义整个工作空间的布局
|
||||
2. **堆栈(Warehouse)** - 存储区域,用于放置载具和物料
|
||||
3. **载具(Carriers)** - 承载瓶子等物料的容器架
|
||||
4. **瓶子(Bottles)** - 实际的物料容器
|
||||
|
||||
本文档以BioYond工作站为例,详细说明如何构建这些物料组件。
|
||||
|
||||
## 文件结构
|
||||
|
||||
物料定义文件位于 `unilabos/resources/` 文件夹中:
|
||||
|
||||
```
|
||||
unilabos/resources/bioyond/
|
||||
├── decks.py # 桌子定义
|
||||
├── YB_warehouses.py # 堆栈定义
|
||||
├── YB_bottle_carriers.py # 载具定义
|
||||
└── YB_bottles.py # 瓶子定义
|
||||
```
|
||||
|
||||
对应的注册表文件位于 `unilabos/registry/resources/bioyond/` 文件夹中:
|
||||
|
||||
```
|
||||
unilabos/registry/resources/bioyond/
|
||||
├── deck.yaml # 桌子注册表
|
||||
├── YB_bottle_carriers.yaml # 载具注册表
|
||||
└── YB_bottle.yaml # 瓶子注册表
|
||||
```
|
||||
|
||||
## 1. 桌子(Deck)构建
|
||||
|
||||
桌子是整个工作站的基础,定义了工作空间的尺寸和各个组件的位置。
|
||||
|
||||
### 代码示例 (decks.py)
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import Coordinate, Deck
|
||||
from unilabos.resources.bioyond.YB_warehouses import (
|
||||
bioyond_warehouse_2x2x1,
|
||||
bioyond_warehouse_3x5x1,
|
||||
bioyond_warehouse_20x1x1,
|
||||
bioyond_warehouse_3x3x1,
|
||||
bioyond_warehouse_10x1x1
|
||||
)
|
||||
|
||||
class BIOYOND_YB_Deck(Deck):
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "YB_Deck",
|
||||
size_x: float = 4150, # 桌子X方向尺寸 (mm)
|
||||
size_y: float = 1400.0, # 桌子Y方向尺寸 (mm)
|
||||
size_z: float = 2670.0, # 桌子Z方向尺寸 (mm)
|
||||
category: str = "deck",
|
||||
setup: bool = False
|
||||
) -> None:
|
||||
super().__init__(name=name, size_x=4150.0, size_y=1400.0, size_z=2670.0)
|
||||
if setup:
|
||||
self.setup() # 当在工作站配置中setup为True时,自动创建并放置所有预定义的堆栈
|
||||
|
||||
def setup(self) -> None:
|
||||
# 定义桌子上的各个仓库区域
|
||||
self.warehouses = {
|
||||
"自动堆栈-左": bioyond_warehouse_2x2x1("自动堆栈-左"),
|
||||
"自动堆栈-右": bioyond_warehouse_2x2x1("自动堆栈-右"),
|
||||
"手动堆栈-左": bioyond_warehouse_3x5x1("手动堆栈-左"),
|
||||
"手动堆栈-右": bioyond_warehouse_3x5x1("手动堆栈-右"),
|
||||
"粉末加样头堆栈": bioyond_warehouse_20x1x1("粉末加样头堆栈"),
|
||||
"配液站内试剂仓库": bioyond_warehouse_3x3x1("配液站内试剂仓库"),
|
||||
"试剂替换仓库": bioyond_warehouse_10x1x1("试剂替换仓库"),
|
||||
}
|
||||
|
||||
# 定义各个仓库在桌子上的坐标位置
|
||||
self.warehouse_locations = {
|
||||
"自动堆栈-左": Coordinate(-100.3, 171.5, 0.0),
|
||||
"自动堆栈-右": Coordinate(3960.1, 155.9, 0.0),
|
||||
"手动堆栈-左": Coordinate(-213.3, 804.4, 0.0),
|
||||
"手动堆栈-右": Coordinate(3960.1, 807.6, 0.0),
|
||||
"粉末加样头堆栈": Coordinate(415.0, 1301.0, 0.0),
|
||||
"配液站内试剂仓库": Coordinate(2162.0, 437.0, 0.0),
|
||||
"试剂替换仓库": Coordinate(1173.0, 802.0, 0.0),
|
||||
}
|
||||
|
||||
# 将仓库分配到桌子的指定位置
|
||||
for warehouse_name, warehouse in self.warehouses.items():
|
||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||
```
|
||||
|
||||
### 在工作站配置中的使用
|
||||
|
||||
当在工作站配置文件中定义桌子时,可以通过`setup`参数控制是否自动建立所有堆栈:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "YB_Bioyond_Deck",
|
||||
"name": "YB_Bioyond_Deck",
|
||||
"children": [],
|
||||
"parent": "bioyond_cell_workstation",
|
||||
"type": "deck",
|
||||
"class": "BIOYOND_YB_Deck",
|
||||
"config": {
|
||||
"type": "BIOYOND_YB_Deck",
|
||||
"setup": true
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
**重要说明**:
|
||||
- 当 `"setup": true` 时,系统会自动调用桌子的 `setup()` 方法
|
||||
- 这将创建并放置所有预定义的堆栈到桌子上的指定位置
|
||||
- 如果 `"setup": false` 或省略该参数,则只创建空桌子,需要手动添加堆栈
|
||||
|
||||
### 关键要点注释
|
||||
|
||||
- `size_x`, `size_y`, `size_z`: 定义桌子的物理尺寸
|
||||
- `warehouses`: 字典类型,包含桌子上所有的仓库区域
|
||||
- `warehouse_locations`: 定义每个仓库在桌子坐标系中的位置
|
||||
- `assign_child_resource()`: 将仓库资源分配到桌子的指定位置
|
||||
- `setup()`: 可选的自动设置方法,初始化时可调用
|
||||
|
||||
## 2. 堆栈(Warehouse)构建
|
||||
|
||||
堆栈定义了存储区域的规格和布局,用于放置载具。
|
||||
|
||||
### 代码示例 (YB_warehouses.py)
|
||||
|
||||
```python
|
||||
from unilabos.resources.warehouse import WareHouse, YB_warehouse_factory
|
||||
|
||||
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
|
||||
"""创建BioYond 1x4x4仓库
|
||||
|
||||
Args:
|
||||
name: 仓库名称
|
||||
|
||||
Returns:
|
||||
WareHouse: 仓库对象
|
||||
"""
|
||||
return YB_warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=1, # X方向位置数量
|
||||
num_items_y=4, # Y方向位置数量
|
||||
num_items_z=4, # Z方向位置数量(层数)
|
||||
dx=10.0, # X方向起始偏移
|
||||
dy=10.0, # Y方向起始偏移
|
||||
dz=10.0, # Z方向起始偏移
|
||||
item_dx=137.0, # X方向间距
|
||||
item_dy=96.0, # Y方向间距
|
||||
item_dz=120.0, # Z方向间距(层高)
|
||||
category="warehouse",
|
||||
)
|
||||
|
||||
def bioyond_warehouse_2x2x1(name: str) -> WareHouse:
|
||||
"""创建BioYond 2x2x1仓库(自动堆栈)"""
|
||||
return YB_warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=2,
|
||||
num_items_y=2,
|
||||
num_items_z=1, # 单层
|
||||
dx=10.0,
|
||||
dy=10.0,
|
||||
dz=10.0,
|
||||
item_dx=137.0,
|
||||
item_dy=96.0,
|
||||
item_dz=120.0,
|
||||
category="YB_warehouse",
|
||||
)
|
||||
```
|
||||
|
||||
### 关键要点注释
|
||||
|
||||
- `num_items_x/y/z`: 定义仓库在各个方向的位置数量
|
||||
- `dx/dy/dz`: 第一个位置的起始偏移坐标
|
||||
- `item_dx/dy/dz`: 相邻位置之间的间距
|
||||
- `category`: 仓库类别,用于分类管理
|
||||
- `YB_warehouse_factory`: 统一的仓库创建工厂函数
|
||||
|
||||
## 3. 载具(Carriers)构建
|
||||
|
||||
载具是承载瓶子的容器架,定义了瓶子的排列方式和位置。
|
||||
|
||||
### 代码示例 (YB_bottle_carriers.py)
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d
|
||||
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
|
||||
from unilabos.resources.bioyond.YB_bottles import YB_pei_ye_xiao_Bottle
|
||||
|
||||
def YB_peiyepingxiaoban(name: str) -> BottleCarrier:
|
||||
"""配液瓶(小)板 - 4x2布局,8个位置
|
||||
|
||||
Args:
|
||||
name: 载具名称
|
||||
|
||||
Returns:
|
||||
BottleCarrier: 载具对象,包含8个配液瓶位置
|
||||
"""
|
||||
|
||||
# 载具物理尺寸 (mm)
|
||||
carrier_size_x = 127.8
|
||||
carrier_size_y = 85.5
|
||||
carrier_size_z = 65.0
|
||||
|
||||
# 瓶位参数
|
||||
bottle_diameter = 35.0 # 瓶子直径
|
||||
bottle_spacing_x = 42.0 # X方向瓶子间距
|
||||
bottle_spacing_y = 35.0 # Y方向瓶子间距
|
||||
|
||||
# 计算起始位置 (居中排列)
|
||||
start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||
|
||||
# 创建瓶位布局:4列x2行
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=4, # 4列
|
||||
num_items_y=2, # 2行
|
||||
dx=start_x,
|
||||
dy=start_y,
|
||||
dz=5.0, # 瓶子底部高度
|
||||
item_dx=bottle_spacing_x,
|
||||
item_dy=bottle_spacing_y,
|
||||
size_x=bottle_diameter,
|
||||
size_y=bottle_diameter,
|
||||
size_z=carrier_size_z,
|
||||
)
|
||||
|
||||
# 为每个瓶位设置名称
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}"
|
||||
|
||||
# 创建载具对象
|
||||
carrier = BottleCarrier(
|
||||
name=name,
|
||||
size_x=carrier_size_x,
|
||||
size_y=carrier_size_y,
|
||||
size_z=carrier_size_z,
|
||||
sites=sites,
|
||||
model="YB_peiyepingxiaoban",
|
||||
)
|
||||
|
||||
# 设置载具布局参数
|
||||
carrier.num_items_x = 4
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
|
||||
# 定义瓶子排列顺序
|
||||
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
|
||||
|
||||
# 为每个位置创建瓶子实例
|
||||
for i in range(8):
|
||||
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_bottle_{ordering[i]}")
|
||||
|
||||
return carrier
|
||||
```
|
||||
|
||||
### 关键要点注释
|
||||
|
||||
- `carrier_size_x/y/z`: 载具的物理尺寸
|
||||
- `bottle_diameter`: 瓶子的直径,用于计算瓶位大小
|
||||
- `bottle_spacing_x/y`: 瓶子之间的间距
|
||||
- `create_ordered_items_2d`: 创建二维排列的瓶位
|
||||
- `sites`: 瓶位字典,存储所有瓶子位置信息
|
||||
- `ordering`: 定义瓶位的命名规则(如A1, A2, B1等)
|
||||
|
||||
## 4. 瓶子(Bottles)构建
|
||||
|
||||
瓶子是最终的物料容器,定义了容器的物理属性。
|
||||
|
||||
### 代码示例 (YB_bottles.py)
|
||||
|
||||
```python
|
||||
from unilabos.resources.itemized_carrier import Bottle
|
||||
|
||||
def YB_pei_ye_xiao_Bottle(
|
||||
name: str,
|
||||
diameter: float = 35.0, # 瓶子直径 (mm)
|
||||
height: float = 60.0, # 瓶子高度 (mm)
|
||||
max_volume: float = 30000.0, # 最大容量 (μL) - 30mL
|
||||
barcode: str = None, # 条码
|
||||
) -> Bottle:
|
||||
"""创建配液瓶(小)
|
||||
|
||||
Args:
|
||||
name: 瓶子名称
|
||||
diameter: 瓶子直径
|
||||
height: 瓶子高度
|
||||
max_volume: 最大容量(微升)
|
||||
barcode: 条码标识
|
||||
|
||||
Returns:
|
||||
Bottle: 瓶子对象
|
||||
"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_pei_ye_xiao_Bottle",
|
||||
)
|
||||
|
||||
def YB_ye_Bottle(
|
||||
name: str,
|
||||
diameter: float = 40.0,
|
||||
height: float = 70.0,
|
||||
max_volume: float = 50000.0, # 最大容量
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
"""创建液体瓶"""
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="YB_ye_Bottle",
|
||||
)
|
||||
```
|
||||
|
||||
### 关键要点注释
|
||||
|
||||
- `diameter`: 瓶子直径,影响瓶位大小计算
|
||||
- `height`: 瓶子高度,用于碰撞检测和移液计算
|
||||
- `max_volume`: 最大容量,单位为微升(μL)
|
||||
- `barcode`: 条码标识,用于瓶子追踪
|
||||
- `model`: 型号标识,用于区分不同类型的瓶子
|
||||
|
||||
## 5. 注册表配置
|
||||
|
||||
创建完物料定义后,需要在注册表中注册这些物料,使系统能够识别和使用它们。
|
||||
|
||||
在 `unilabos/registry/resources/bioyond/` 目录下创建:
|
||||
|
||||
- `deck.yaml` - 桌子注册表
|
||||
- `YB_bottle_carriers.yaml` - 载具注册表
|
||||
- `YB_bottle.yaml` - 瓶子注册表
|
||||
|
||||
### 5.1 桌子注册表 (deck.yaml)
|
||||
|
||||
```yaml
|
||||
BIOYOND_YB_Deck:
|
||||
category:
|
||||
- deck # 前端显示的分类存放
|
||||
class:
|
||||
module: unilabos.resources.bioyond.decks:BIOYOND_YB_Deck # 定义桌子的类的路径
|
||||
type: pylabrobot
|
||||
description: BIOYOND_YB_Deck # 描述信息
|
||||
handles: []
|
||||
icon: 配液站.webp # 图标文件
|
||||
init_param_schema: {}
|
||||
registry_type: resource # 注册类型
|
||||
version: 1.0.0 # 版本号
|
||||
```
|
||||
|
||||
### 5.2 载具注册表 (YB_bottle_carriers.yaml)
|
||||
|
||||
```yaml
|
||||
YB_peiyepingxiaoban:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingxiaoban
|
||||
type: pylabrobot
|
||||
description: YB_peiyepingxiaoban
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
```
|
||||
|
||||
### 5.3 瓶子注册表 (YB_bottle.yaml)
|
||||
|
||||
```yaml
|
||||
YB_pei_ye_xiao_Bottle:
|
||||
category:
|
||||
- yb3
|
||||
- YB_bottle
|
||||
class:
|
||||
module: unilabos.resources.bioyond.YB_bottles:YB_pei_ye_xiao_Bottle
|
||||
type: pylabrobot
|
||||
description: YB_pei_ye_xiao_Bottle
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
```
|
||||
|
||||
### 注册表关键要点注释
|
||||
|
||||
- `category`: 物料分类,用于在云端(网页界面)中的分类中显示
|
||||
- `module`: Python模块路径,格式为 `模块路径:类名`
|
||||
- `type`: 框架类型,通常为 `pylabrobot`(默认即可)
|
||||
- `description`: 描述信息,显示在用户界面中
|
||||
- `icon`: (名称唯一自动匹配后端上传的图标文件名,显示在云端)
|
||||
- `registry_type`: 固定为 `resource`
|
||||
- `version`: 版本号,用于版本管理
|
||||
413
docs/developer_guide/examples/materials_tutorial.md
Normal file
@@ -0,0 +1,413 @@
|
||||
# 实例:物料教程(Resource)
|
||||
|
||||
> **文档类型**:物料系统完整教程
|
||||
> **适用场景**:物料格式转换、多系统物料对接、资源结构理解
|
||||
> **前置知识**:Python 基础 | JSON 数据结构
|
||||
|
||||
本教程面向 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 PyLabRobot(PLR)物料格式(实验流程运行时)
|
||||
|
||||
- 结构特征:严格的层级树,`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]:
|
||||
# ... 由扁平 dict(id->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 ↔ PyLabRobot(PLR)
|
||||
|
||||
高层封装:
|
||||
|
||||
```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 列表)。便于版本化、可视化与网络传输。
|
||||
- **实验工作流执行**:使用 PyLabRobot(PLR)格式。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]:
|
||||
# 批量初始化
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
782
docs/developer_guide/examples/workstation_architecture.md
Normal file
@@ -0,0 +1,782 @@
|
||||
# 实例:工作站模板架构设计与对接指南
|
||||
|
||||
> **文档类型**:架构设计指南与实战案例
|
||||
> **适用场景**:大型工作站接入、子设备管理、物料系统集成
|
||||
> **前置知识**:{doc}`../add_device` | {doc}`../add_registry`
|
||||
|
||||
## 0. 问题简介
|
||||
|
||||
我们可以从以下几类例子,来理解对接大型工作站需要哪些设计。本文档之后的实战案例也将由这些组成。
|
||||
|
||||
### 0.1 自研常量有机工站:最重要的是子设备管理和通信转发
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
这类工站由开发者自研,组合所有子设备和实验耗材、希望让他们在工作站这一级协调配合;
|
||||
|
||||
1. 工作站包含大量已经注册的子设备,可能各自通信组态很不相同;部分设备可能会拥有同一个通信设备作为出口,如 2 个泵共用 1 个串口、所有设备共同接入 PLC 等。
|
||||
2. 任务系统是统一实现的 protocols,protocols 中会将高层指令处理成各子设备配合的工作流 json 并管理执行、同时更改物料信息
|
||||
3. 物料系统较为简单直接,如常量有机化学仅为工作站内固定的瓶子,初始化时就已固定;随后在任务执行过程中,记录试剂量更改信息
|
||||
|
||||
### 0.2 移液工作站:物料系统和工作流模板管理
|
||||
|
||||

|
||||
|
||||
1. 绝大多数情况没有子设备,有时候选配恒温震荡等模块时,接口也由工作站提供
|
||||
2. 所有任务系统均由工作站本身实现并下发指令,有统一的抽象函数可实现(pick_up_tips, aspirate, dispense, transfer 等)。有时需要将这些指令组合、转化为工作站的脚本语言,再统一下发。因此会形成大量固定的 protocols。
|
||||
3. 物料系统为固定的板位系统:台面上有多个可摆放位置,摆放标准孔板。
|
||||
|
||||
### 0.3 厂家开发的定制大型工站
|
||||
|
||||

|
||||
|
||||
由厂家开发,具备完善的物料系统、任务系统甚至调度系统;由 PLC 或 OpenAPI TCP 协议统一通信
|
||||
|
||||
1. 在监控状态时,希望展现子设备的状态;但子设备仅为逻辑概念,通信由工作站上位机接口提供;部分情况下,子设备状态是被记录在文件中的,需要读取
|
||||
2. 工作站有自己的工作流系统甚至调度系统;可以通过脚本/PLC 连续读写来配置工作站可用的工作流;
|
||||
3. 部分拥有完善的物料入库、出库、过程记录,需要与 Uni-Lab-OS 物料系统对接
|
||||
|
||||
## 1. 整体架构图
|
||||
|
||||
### 1.1 工作站核心架构
|
||||
|
||||
```{mermaid}
|
||||
graph TB
|
||||
subgraph "工作站模板组成"
|
||||
WB[WorkstationBase<br/>工作流状态管理]
|
||||
RPN[ROS2WorkstationNode<br/>Protocol执行引擎]
|
||||
WB -.post_init关联.-> RPN
|
||||
end
|
||||
|
||||
subgraph "物料管理系统"
|
||||
DECK[Deck<br/>PLR本地物料系统]
|
||||
RS[ResourceSynchronizer<br/>外部物料同步器]
|
||||
WB --> DECK
|
||||
WB --> RS
|
||||
RS --> DECK
|
||||
end
|
||||
|
||||
subgraph "通信与子设备管理"
|
||||
HW[hardware_interface<br/>硬件通信接口]
|
||||
SUBDEV[子设备集合<br/>pumps/grippers/sensors]
|
||||
WB --> HW
|
||||
RPN --> SUBDEV
|
||||
HW -.代理模式.-> RPN
|
||||
end
|
||||
|
||||
subgraph "工作流任务系统"
|
||||
PROTO[Protocol定义<br/>LiquidHandling/PlateHandling]
|
||||
WORKFLOW[Workflow执行器<br/>步骤管理与编排]
|
||||
RPN --> PROTO
|
||||
RPN --> WORKFLOW
|
||||
WORKFLOW --> SUBDEV
|
||||
end
|
||||
```
|
||||
|
||||
### 1.2 外部系统对接关系
|
||||
|
||||
```{mermaid}
|
||||
graph LR
|
||||
subgraph "Uni-Lab-OS工作站"
|
||||
WS[WorkstationBase + ROS2WorkstationNode]
|
||||
DECK2[物料系统<br/>Deck]
|
||||
HW2[通信接口<br/>hardware_interface]
|
||||
HTTP[HTTP服务<br/>WorkstationHTTPService]
|
||||
end
|
||||
|
||||
subgraph "外部物料系统"
|
||||
BIOYOND[Bioyond物料管理]
|
||||
LIMS[LIMS系统]
|
||||
WAREHOUSE[第三方仓储]
|
||||
end
|
||||
|
||||
subgraph "外部硬件系统"
|
||||
PLC[PLC设备]
|
||||
SERIAL[串口设备]
|
||||
ROBOT[机械臂/机器人]
|
||||
end
|
||||
|
||||
subgraph "云端系统"
|
||||
CLOUD[UniLab云端<br/>资源管理]
|
||||
MONITOR[监控与调度]
|
||||
end
|
||||
|
||||
BIOYOND <-->|RPC双向同步| DECK2
|
||||
LIMS -->|HTTP报送| HTTP
|
||||
WAREHOUSE <-->|API对接| DECK2
|
||||
|
||||
PLC <-->|Modbus TCP| HW2
|
||||
SERIAL <-->|串口通信| HW2
|
||||
ROBOT <-->|SDK/API| HW2
|
||||
|
||||
WS -->|ROS消息| CLOUD
|
||||
CLOUD -->|任务下发| WS
|
||||
MONITOR -->|状态查询| WS
|
||||
```
|
||||
|
||||
### 1.3 具体实现示例
|
||||
|
||||
```{mermaid}
|
||||
graph TB
|
||||
subgraph "工作站基类"
|
||||
BASE[WorkstationBase<br/>抽象基类]
|
||||
end
|
||||
|
||||
subgraph "Bioyond集成工作站"
|
||||
BW[BioyondWorkstation]
|
||||
BW_DECK[Deck + Warehouses]
|
||||
BW_SYNC[BioyondResourceSynchronizer]
|
||||
BW_HW[BioyondV1RPC]
|
||||
BW_HTTP[HTTP报送服务]
|
||||
|
||||
BW --> BW_DECK
|
||||
BW --> BW_SYNC
|
||||
BW --> BW_HW
|
||||
BW --> BW_HTTP
|
||||
end
|
||||
|
||||
subgraph "纯协议节点"
|
||||
PN[ProtocolNode]
|
||||
PN_SUB[子设备集合]
|
||||
PN_PROTO[Protocol工作流]
|
||||
|
||||
PN --> PN_SUB
|
||||
PN --> PN_PROTO
|
||||
end
|
||||
|
||||
subgraph "PLC控制工作站"
|
||||
PW[PLCWorkstation]
|
||||
PW_DECK[Deck物料系统]
|
||||
PW_PLC[Modbus PLC客户端]
|
||||
PW_WF[工作流定义]
|
||||
|
||||
PW --> PW_DECK
|
||||
PW --> PW_PLC
|
||||
PW --> PW_WF
|
||||
end
|
||||
|
||||
BASE -.继承.-> BW
|
||||
BASE -.继承.-> PN
|
||||
BASE -.继承.-> PW
|
||||
```
|
||||
|
||||
## 2. 类关系图
|
||||
|
||||
```{mermaid}
|
||||
classDiagram
|
||||
class WorkstationBase {
|
||||
<<abstract>>
|
||||
+_ros_node: ROS2WorkstationNode
|
||||
+deck: Deck
|
||||
+plr_resources: Dict[str, PLRResource]
|
||||
+resource_synchronizer: ResourceSynchronizer
|
||||
+hardware_interface: Union[Any, str]
|
||||
+current_workflow_status: WorkflowStatus
|
||||
+supported_workflows: Dict[str, WorkflowInfo]
|
||||
|
||||
+post_init(ros_node)*
|
||||
+set_hardware_interface(interface)
|
||||
+call_device_method(method, *args, **kwargs)
|
||||
+get_device_status()
|
||||
+is_device_available()
|
||||
|
||||
+get_deck()
|
||||
+get_all_resources()
|
||||
+find_resource_by_name(name)
|
||||
+find_resources_by_type(type)
|
||||
+sync_with_external_system()
|
||||
|
||||
+execute_workflow(name, params)
|
||||
+stop_workflow(emergency)
|
||||
+workflow_status
|
||||
+is_busy
|
||||
}
|
||||
|
||||
class ROS2WorkstationNode {
|
||||
+device_id: str
|
||||
+children: Dict[str, Any]
|
||||
+sub_devices: Dict
|
||||
+protocol_names: List[str]
|
||||
+_action_clients: Dict
|
||||
+_action_servers: Dict
|
||||
+resource_tracker: DeviceNodeResourceTracker
|
||||
|
||||
+initialize_device(device_id, config)
|
||||
+create_ros_action_server(action_name, mapping)
|
||||
+execute_single_action(device_id, action, kwargs)
|
||||
+update_resource(resources)
|
||||
+transfer_resource_to_another(resources, target, sites)
|
||||
+_setup_hardware_proxy(device, comm_device, read, write)
|
||||
}
|
||||
|
||||
%% 物料管理相关类
|
||||
class Deck {
|
||||
+name: str
|
||||
+children: List
|
||||
+assign_child_resource()
|
||||
}
|
||||
|
||||
class ResourceSynchronizer {
|
||||
<<abstract>>
|
||||
+workstation: WorkstationBase
|
||||
+sync_from_external()*
|
||||
+sync_to_external(plr_resource)*
|
||||
+handle_external_change(change_info)*
|
||||
}
|
||||
|
||||
class BioyondResourceSynchronizer {
|
||||
+bioyond_api_client: BioyondV1RPC
|
||||
+sync_interval: int
|
||||
+last_sync_time: float
|
||||
|
||||
+initialize()
|
||||
+sync_from_external()
|
||||
+sync_to_external(resource)
|
||||
+handle_external_change(change_info)
|
||||
}
|
||||
|
||||
%% 硬件接口相关类
|
||||
class HardwareInterface {
|
||||
<<interface>>
|
||||
}
|
||||
|
||||
class BioyondV1RPC {
|
||||
+base_url: str
|
||||
+api_key: str
|
||||
+stock_material()
|
||||
+add_material()
|
||||
+material_inbound()
|
||||
}
|
||||
|
||||
%% 服务类
|
||||
class WorkstationHTTPService {
|
||||
+workstation: WorkstationBase
|
||||
+host: str
|
||||
+port: int
|
||||
+server: HTTPServer
|
||||
+running: bool
|
||||
|
||||
+start()
|
||||
+stop()
|
||||
+_handle_step_finish_report()
|
||||
+_handle_sample_finish_report()
|
||||
+_handle_order_finish_report()
|
||||
+_handle_material_change_report()
|
||||
+_handle_error_handling_report()
|
||||
}
|
||||
|
||||
%% 具体实现类
|
||||
class BioyondWorkstation {
|
||||
+bioyond_config: Dict
|
||||
+workflow_mappings: Dict
|
||||
+workflow_sequence: List
|
||||
|
||||
+post_init(ros_node)
|
||||
+transfer_resource_to_another()
|
||||
+resource_tree_add(resources)
|
||||
+append_to_workflow_sequence(name)
|
||||
+get_all_workflows()
|
||||
+get_bioyond_status()
|
||||
}
|
||||
|
||||
class ProtocolNode {
|
||||
+post_init(ros_node)
|
||||
}
|
||||
|
||||
%% 核心关系
|
||||
WorkstationBase o-- ROS2WorkstationNode : post_init关联
|
||||
WorkstationBase o-- WorkstationHTTPService : 可选服务
|
||||
|
||||
%% 物料管理侧
|
||||
WorkstationBase *-- Deck : deck
|
||||
WorkstationBase *-- ResourceSynchronizer : 可选组合
|
||||
ResourceSynchronizer <|-- BioyondResourceSynchronizer
|
||||
|
||||
%% 硬件接口侧
|
||||
WorkstationBase o-- HardwareInterface : hardware_interface
|
||||
HardwareInterface <|.. BioyondV1RPC : 实现
|
||||
BioyondResourceSynchronizer --> BioyondV1RPC : 使用
|
||||
|
||||
%% 继承关系
|
||||
BioyondWorkstation --|> WorkstationBase
|
||||
ProtocolNode --|> WorkstationBase
|
||||
ROS2WorkstationNode --|> BaseROS2DeviceNode : 继承
|
||||
```
|
||||
|
||||
## 3. 工作站启动时序图
|
||||
|
||||
```{mermaid}
|
||||
sequenceDiagram
|
||||
participant APP as Application
|
||||
participant WS as WorkstationBase
|
||||
participant DECK as PLR Deck
|
||||
participant SYNC as ResourceSynchronizer
|
||||
participant HW as HardwareInterface
|
||||
participant ROS as ROS2WorkstationNode
|
||||
participant HTTP as HTTPService
|
||||
|
||||
APP->>WS: 创建工作站实例(__init__)
|
||||
WS->>DECK: 初始化PLR Deck
|
||||
DECK->>DECK: 创建Warehouse等子资源
|
||||
DECK-->>WS: Deck创建完成
|
||||
|
||||
WS->>HW: 创建硬件接口(如BioyondV1RPC)
|
||||
HW->>HW: 建立连接(PLC/RPC/串口等)
|
||||
HW-->>WS: 硬件接口就绪
|
||||
|
||||
WS->>SYNC: 创建ResourceSynchronizer(可选)
|
||||
SYNC->>HW: 使用hardware_interface
|
||||
SYNC->>SYNC: 初始化同步配置
|
||||
SYNC-->>WS: 同步器创建完成
|
||||
|
||||
WS->>SYNC: sync_from_external()
|
||||
SYNC->>HW: 查询外部物料系统
|
||||
HW-->>SYNC: 返回物料数据
|
||||
SYNC->>DECK: 转换并添加到Deck
|
||||
SYNC-->>WS: 同步完成
|
||||
|
||||
Note over WS: __init__完成,等待ROS节点
|
||||
|
||||
APP->>ROS: 初始化ROS2WorkstationNode
|
||||
ROS->>ROS: 初始化子设备(children)
|
||||
ROS->>ROS: 创建Action客户端
|
||||
ROS->>ROS: 设置硬件接口代理
|
||||
ROS-->>APP: ROS节点就绪
|
||||
|
||||
APP->>WS: post_init(ros_node)
|
||||
WS->>WS: self._ros_node = ros_node
|
||||
WS->>ROS: update_resource([deck])
|
||||
ROS->>ROS: 上传物料到云端
|
||||
ROS-->>WS: 上传完成
|
||||
|
||||
WS->>HTTP: 创建WorkstationHTTPService(可选)
|
||||
HTTP->>HTTP: 启动HTTP服务器线程
|
||||
HTTP-->>WS: HTTP服务启动
|
||||
|
||||
WS-->>APP: 工作站完全就绪
|
||||
```
|
||||
|
||||
## 4. 工作流执行时序图(Protocol 模式)
|
||||
|
||||
```{mermaid}
|
||||
sequenceDiagram
|
||||
participant CLIENT as 客户端
|
||||
participant ROS as ROS2WorkstationNode
|
||||
participant WS as WorkstationBase
|
||||
participant HW as HardwareInterface
|
||||
participant DECK as PLR Deck
|
||||
participant CLOUD as 云端资源管理
|
||||
participant DEV as 子设备
|
||||
|
||||
CLIENT->>ROS: 发送Protocol Action请求
|
||||
ROS->>ROS: execute_protocol回调
|
||||
ROS->>ROS: 从Goal提取参数
|
||||
ROS->>ROS: 调用protocol_steps_generator
|
||||
ROS->>ROS: 生成action步骤列表
|
||||
|
||||
ROS->>WS: 更新workflow_status = RUNNING
|
||||
|
||||
loop 执行每个步骤
|
||||
alt 调用子设备
|
||||
ROS->>ROS: execute_single_action(device_id, action, params)
|
||||
ROS->>DEV: 发送Action Goal(通过Action Client)
|
||||
DEV->>DEV: 执行设备动作
|
||||
DEV-->>ROS: 返回Result
|
||||
else 调用工作站自身
|
||||
ROS->>WS: call_device_method(method, *args)
|
||||
alt 直接模式
|
||||
WS->>HW: 调用hardware_interface方法
|
||||
HW->>HW: 执行硬件操作
|
||||
HW-->>WS: 返回结果
|
||||
else 代理模式
|
||||
WS->>ROS: 转发到子设备
|
||||
ROS->>DEV: 调用子设备方法
|
||||
DEV-->>ROS: 返回结果
|
||||
ROS-->>WS: 返回结果
|
||||
end
|
||||
WS-->>ROS: 返回结果
|
||||
end
|
||||
|
||||
ROS->>DECK: 更新本地物料状态
|
||||
DECK->>DECK: 修改PLR资源属性
|
||||
end
|
||||
|
||||
ROS->>CLOUD: 同步物料到云端(可选)
|
||||
CLOUD-->>ROS: 同步完成
|
||||
|
||||
ROS->>WS: 更新workflow_status = COMPLETED
|
||||
ROS-->>CLIENT: 返回Protocol Result
|
||||
```
|
||||
|
||||
## 5. HTTP 报送处理时序图
|
||||
|
||||
```{mermaid}
|
||||
sequenceDiagram
|
||||
participant EXT as 外部工作站/LIMS
|
||||
participant HTTP as HTTPService
|
||||
participant WS as WorkstationBase
|
||||
participant DECK as PLR Deck
|
||||
participant SYNC as ResourceSynchronizer
|
||||
participant CLOUD as 云端
|
||||
|
||||
EXT->>HTTP: POST /report/step_finish
|
||||
HTTP->>HTTP: 解析请求数据
|
||||
HTTP->>HTTP: 验证LIMS协议字段
|
||||
HTTP->>WS: process_step_finish_report(request)
|
||||
|
||||
WS->>WS: 增加接收计数(_reports_received_count++)
|
||||
WS->>WS: 记录步骤完成事件
|
||||
WS->>DECK: 更新相关物料状态(可选)
|
||||
DECK->>DECK: 修改PLR资源状态
|
||||
|
||||
WS->>WS: 保存报送记录到内存
|
||||
|
||||
WS-->>HTTP: 返回处理结果
|
||||
HTTP->>HTTP: 构造HTTP响应
|
||||
HTTP-->>EXT: 200 OK + acknowledgment_id
|
||||
|
||||
Note over EXT,CLOUD: 类似处理sample_finish, order_finish等报送
|
||||
|
||||
alt 物料变更报送
|
||||
EXT->>HTTP: POST /report/material_change
|
||||
HTTP->>WS: process_material_change_report(data)
|
||||
WS->>DECK: 查找或创建物料
|
||||
WS->>SYNC: sync_to_external(resource)
|
||||
SYNC->>SYNC: 同步到外部系统(如Bioyond)
|
||||
SYNC-->>WS: 同步完成
|
||||
WS->>CLOUD: update_resource(通过ROS节点)
|
||||
CLOUD-->>WS: 上传完成
|
||||
WS-->>HTTP: 返回结果
|
||||
HTTP-->>EXT: 200 OK
|
||||
end
|
||||
```
|
||||
|
||||
## 6. 错误处理时序图
|
||||
|
||||
```{mermaid}
|
||||
sequenceDiagram
|
||||
participant DEV as 子设备/外部系统
|
||||
participant ROS as ROS2WorkstationNode
|
||||
participant WS as WorkstationBase
|
||||
participant HW as HardwareInterface
|
||||
participant HTTP as HTTPService
|
||||
participant LOG as 日志系统
|
||||
|
||||
alt 设备错误(ROS Action失败)
|
||||
DEV->>ROS: Action返回失败结果
|
||||
ROS->>ROS: 记录错误信息
|
||||
ROS->>WS: 更新workflow_status = ERROR
|
||||
ROS->>LOG: 记录错误日志
|
||||
else 外部系统错误报送
|
||||
DEV->>HTTP: POST /report/error_handling
|
||||
HTTP->>WS: handle_external_error(error_data)
|
||||
WS->>WS: 记录错误历史
|
||||
WS->>LOG: 记录错误日志
|
||||
end
|
||||
|
||||
alt 关键错误需要停止
|
||||
WS->>ROS: stop_workflow(emergency=True)
|
||||
ROS->>ROS: 取消所有进行中的Action
|
||||
ROS->>HW: 调用emergency_stop()(如果支持)
|
||||
HW->>HW: 执行紧急停止
|
||||
WS->>WS: 更新workflow_status = ERROR
|
||||
else 可恢复错误
|
||||
WS->>WS: 标记步骤失败
|
||||
WS->>ROS: 触发重试逻辑(可选)
|
||||
ROS->>DEV: 重新发送Action
|
||||
end
|
||||
|
||||
WS-->>HTTP: 返回错误处理结果
|
||||
HTTP-->>DEV: 200 OK + 处理状态
|
||||
```
|
||||
|
||||
## 7. 典型工作站实现示例
|
||||
|
||||
### 7.1 Bioyond 集成工作站实现
|
||||
|
||||
```python
|
||||
class BioyondWorkstation(WorkstationBase):
|
||||
def __init__(self, bioyond_config: Dict, deck: Deck, *args, **kwargs):
|
||||
# 初始化deck
|
||||
super().__init__(deck=deck, *args, **kwargs)
|
||||
|
||||
# 设置硬件接口为Bioyond RPC客户端
|
||||
self.hardware_interface = BioyondV1RPC(bioyond_config)
|
||||
|
||||
# 创建资源同步器
|
||||
self.resource_synchronizer = BioyondResourceSynchronizer(self)
|
||||
|
||||
# 从Bioyond同步物料到本地deck
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
# 配置工作流
|
||||
self.workflow_mappings = bioyond_config.get("workflow_mappings", {})
|
||||
|
||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||
"""ROS节点就绪后的初始化"""
|
||||
self._ros_node = ros_node
|
||||
|
||||
# 上传deck(包括所有物料)到云端
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource,
|
||||
True,
|
||||
resources=[self.deck]
|
||||
)
|
||||
|
||||
def resource_tree_add(self, resources: List[ResourcePLR]):
|
||||
"""添加物料并同步到Bioyond"""
|
||||
for resource in resources:
|
||||
self.deck.assign_child_resource(resource, location)
|
||||
self.resource_synchronizer.sync_to_external(resource)
|
||||
```
|
||||
|
||||
### 7.2 纯协议节点实现
|
||||
|
||||
```python
|
||||
class ProtocolNode(WorkstationBase):
|
||||
"""纯协议节点,不需要物料管理和外部通信"""
|
||||
|
||||
def __init__(self, deck: Optional[Deck] = None, *args, **kwargs):
|
||||
super().__init__(deck=deck, *args, **kwargs)
|
||||
# 不设置hardware_interface和resource_synchronizer
|
||||
# 所有功能通过子设备协同完成
|
||||
|
||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||
self._ros_node = ros_node
|
||||
# 不需要上传物料或其他初始化
|
||||
```
|
||||
|
||||
### 7.3 PLC 直接控制工作站
|
||||
|
||||
```python
|
||||
class PLCWorkstation(WorkstationBase):
|
||||
def __init__(self, plc_config: Dict, deck: Deck, *args, **kwargs):
|
||||
super().__init__(deck=deck, *args, **kwargs)
|
||||
|
||||
# 设置硬件接口为Modbus客户端
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
self.hardware_interface = ModbusTcpClient(
|
||||
host=plc_config["host"],
|
||||
port=plc_config["port"]
|
||||
)
|
||||
self.hardware_interface.connect()
|
||||
|
||||
# 定义支持的工作流
|
||||
self.supported_workflows = {
|
||||
"battery_assembly": WorkflowInfo(
|
||||
name="电池组装",
|
||||
description="自动化电池组装流程",
|
||||
estimated_duration=300.0,
|
||||
required_materials=["battery_cell", "connector"],
|
||||
output_product="battery_pack",
|
||||
parameters_schema={"quantity": int, "model": str}
|
||||
)
|
||||
}
|
||||
|
||||
def execute_workflow(self, workflow_name: str, parameters: Dict):
|
||||
"""通过PLC执行工作流"""
|
||||
workflow_id = self._get_workflow_id(workflow_name)
|
||||
|
||||
# 写入PLC寄存器启动工作流
|
||||
self.hardware_interface.write_register(100, workflow_id)
|
||||
self.hardware_interface.write_register(101, parameters["quantity"])
|
||||
|
||||
self.current_workflow_status = WorkflowStatus.RUNNING
|
||||
return True
|
||||
```
|
||||
|
||||
## 8. 核心接口说明
|
||||
|
||||
### 8.1 WorkstationBase 核心属性
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
| ------------------------- | ----------------------- | ------------------------------- |
|
||||
| `_ros_node` | ROS2WorkstationNode | ROS 节点引用,由 post_init 设置 |
|
||||
| `deck` | Deck | PyLabRobot Deck,本地物料系统 |
|
||||
| `plr_resources` | Dict[str, PLRResource] | 物料资源映射 |
|
||||
| `resource_synchronizer` | ResourceSynchronizer | 外部物料同步器(可选) |
|
||||
| `hardware_interface` | Union[Any, str] | 硬件接口或代理字符串 |
|
||||
| `current_workflow_status` | WorkflowStatus | 当前工作流状态 |
|
||||
| `supported_workflows` | Dict[str, WorkflowInfo] | 支持的工作流定义 |
|
||||
|
||||
### 8.2 必须实现的方法
|
||||
|
||||
- `post_init(ros_node)`: ROS 节点就绪后的初始化,必须实现
|
||||
|
||||
### 8.3 硬件接口相关方法
|
||||
|
||||
- `set_hardware_interface(interface)`: 设置硬件接口
|
||||
- `call_device_method(method, *args, **kwargs)`: 统一设备方法调用
|
||||
- 支持直接模式: 直接调用 hardware_interface 的方法
|
||||
- 支持代理模式: hardware_interface="proxy:device_id"通过 ROS 转发
|
||||
- `get_device_status()`: 获取设备状态
|
||||
- `is_device_available()`: 检查设备可用性
|
||||
|
||||
### 8.4 物料管理方法
|
||||
|
||||
- `get_deck()`: 获取 PLR Deck
|
||||
- `get_all_resources()`: 获取所有物料
|
||||
- `find_resource_by_name(name)`: 按名称查找物料
|
||||
- `find_resources_by_type(type)`: 按类型查找物料
|
||||
- `sync_with_external_system()`: 触发外部同步
|
||||
|
||||
### 8.5 工作流控制方法
|
||||
|
||||
- `execute_workflow(name, params)`: 执行工作流
|
||||
- `stop_workflow(emergency)`: 停止工作流
|
||||
- `workflow_status`: 获取工作流状态(属性)
|
||||
- `is_busy`: 检查是否忙碌(属性)
|
||||
- `workflow_runtime`: 获取运行时间(属性)
|
||||
|
||||
### 8.6 可选的 HTTP 报送处理方法
|
||||
|
||||
- `process_step_finish_report()`: 步骤完成处理
|
||||
- `process_sample_finish_report()`: 样本完成处理
|
||||
- `process_order_finish_report()`: 订单完成处理
|
||||
- `process_material_change_report()`: 物料变更处理
|
||||
- `handle_external_error()`: 错误处理
|
||||
|
||||
### 8.7 ROS2WorkstationNode 核心方法
|
||||
|
||||
- `initialize_device(device_id, config)`: 初始化子设备
|
||||
- `create_ros_action_server(action_name, mapping)`: 创建 Action 服务器
|
||||
- `execute_single_action(device_id, action, kwargs)`: 执行单个动作
|
||||
- `update_resource(resources)`: 同步物料到云端
|
||||
- `transfer_resource_to_another(...)`: 跨设备物料转移
|
||||
|
||||
## 9. 配置参数说明
|
||||
|
||||
### 9.1 工作站初始化配置
|
||||
|
||||
```python
|
||||
# 示例1: Bioyond集成工作站
|
||||
bioyond_config = {
|
||||
"base_url": "http://192.168.1.100:8080",
|
||||
"api_key": "your_api_key",
|
||||
"sync_interval": 600, # 同步间隔(秒)
|
||||
"workflow_mappings": {
|
||||
"样品制备": "workflow_uuid_1",
|
||||
"质检流程": "workflow_uuid_2"
|
||||
},
|
||||
"material_type_mappings": {
|
||||
"plate": "板",
|
||||
"tube": "试管"
|
||||
},
|
||||
"warehouse_mapping": {
|
||||
"冷藏区": {
|
||||
"uuid": "warehouse_uuid_1",
|
||||
"locations": {...}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 创建Deck
|
||||
from pylabrobot.resources import Deck
|
||||
deck = Deck(name="main_deck", size_x=1000, size_y=800, size_z=200)
|
||||
|
||||
workstation = BioyondWorkstation(
|
||||
bioyond_config=bioyond_config,
|
||||
deck=deck
|
||||
)
|
||||
```
|
||||
|
||||
### 9.2 子设备配置(children)
|
||||
|
||||
```python
|
||||
# 在devices.json中配置
|
||||
{
|
||||
"bioyond_workstation": {
|
||||
"type": "protocol", # 表示这是工作站节点
|
||||
"protocol_type": ["LiquidHandling", "PlateHandling"],
|
||||
"children": {
|
||||
"pump_1": {
|
||||
"type": "device",
|
||||
"driver": "TricontInnovaDriver",
|
||||
"communication": "serial_1",
|
||||
"config": {...}
|
||||
},
|
||||
"gripper_1": {
|
||||
"type": "device",
|
||||
"driver": "RobotiqGripperDriver",
|
||||
"communication": "io_modbus_1",
|
||||
"config": {...}
|
||||
},
|
||||
"serial_1": {
|
||||
"type": "communication",
|
||||
"protocol": "serial",
|
||||
"port": "/dev/ttyUSB0",
|
||||
"baudrate": 9600
|
||||
},
|
||||
"io_modbus_1": {
|
||||
"type": "communication",
|
||||
"protocol": "modbus_tcp",
|
||||
"host": "192.168.1.101",
|
||||
"port": 502
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 HTTP 服务配置
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
|
||||
# 创建HTTP服务(可选)
|
||||
http_service = WorkstationHTTPService(
|
||||
workstation_instance=workstation,
|
||||
host="0.0.0.0", # 监听所有网卡
|
||||
port=8081
|
||||
)
|
||||
http_service.start()
|
||||
```
|
||||
|
||||
## 10. 架构设计特点总结
|
||||
|
||||
这个简化后的架构设计具有以下特点:
|
||||
|
||||
### 10.1 清晰的职责分离
|
||||
|
||||
- **WorkstationBase**: 负责物料管理(deck)、硬件接口(hardware_interface)、工作流状态管理
|
||||
- **ROS2WorkstationNode**: 负责子设备管理、Protocol 执行、云端物料同步
|
||||
- **ResourceSynchronizer**: 可选的外部物料系统同步(如 Bioyond)
|
||||
- **WorkstationHTTPService**: 可选的 HTTP 报送接收服务
|
||||
|
||||
### 10.2 灵活的硬件接口模式
|
||||
|
||||
1. **直接模式**: hardware_interface 是具体对象(如 BioyondV1RPC、ModbusClient)
|
||||
2. **代理模式**: hardware_interface="proxy:device_id",通过 ROS 节点转发到子设备
|
||||
3. **混合模式**: 工作站有自己的接口,同时管理多个子设备
|
||||
|
||||
### 10.3 统一的物料系统
|
||||
|
||||
- 基于 PyLabRobot Deck 的标准化物料表示
|
||||
- 通过 ResourceSynchronizer 实现与外部系统(如 Bioyond、LIMS)的双向同步
|
||||
- 通过 ROS2WorkstationNode 实现与云端的物料状态同步
|
||||
|
||||
### 10.4 Protocol 驱动的工作流
|
||||
|
||||
- ROS2WorkstationNode 负责 Protocol 的执行和步骤管理
|
||||
- 支持子设备协同(通过 Action Client 调用)
|
||||
- 支持工作站直接控制(通过 hardware_interface)
|
||||
|
||||
### 10.5 可选的 HTTP 报送服务
|
||||
|
||||
- 基于 LIMS 协议规范的统一报送接口
|
||||
- 支持步骤完成、样本完成、任务完成、物料变更等多种报送类型
|
||||
- 与工作站解耦,可独立启停
|
||||
|
||||
### 10.6 简化的初始化流程
|
||||
|
||||
```
|
||||
1. __init__: 创建deck、设置hardware_interface、创建resource_synchronizer
|
||||
2. 从外部系统同步物料(如果有)
|
||||
3. ROS节点初始化子设备
|
||||
4. post_init: 关联ROS节点、上传物料到云端
|
||||
5. (可选)启动HTTP服务
|
||||
```
|
||||
|
||||
这种设计既保持了灵活性,又避免了过度抽象,更适合实际的工作站对接场景。
|
||||
334
docs/developer_guide/http_api.md
Normal file
@@ -0,0 +1,334 @@
|
||||
# HTTP API 指南
|
||||
|
||||
本文档介绍如何通过 HTTP API 与 Uni-Lab-OS 进行交互,包括查询设备、提交任务和获取结果。
|
||||
|
||||
## 概述
|
||||
|
||||
Uni-Lab-OS 提供 RESTful HTTP API,允许外部系统通过标准 HTTP 请求控制实验室设备。API 基于 FastAPI 构建,默认运行在 `http://localhost:8002`。
|
||||
|
||||
### 基础信息
|
||||
|
||||
- **Base URL**: `http://localhost:8002/api/v1`
|
||||
- **Content-Type**: `application/json`
|
||||
- **响应格式**: JSON
|
||||
|
||||
### 通用响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": { ... },
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------- | ------ | ------------------ |
|
||||
| `code` | int | 状态码,0 表示成功 |
|
||||
| `data` | object | 响应数据 |
|
||||
| `message` | string | 响应消息 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
以下是一个完整的工作流示例:查询设备 → 获取动作 → 提交任务 → 获取结果。
|
||||
|
||||
### 步骤 1: 获取在线设备
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8002/api/v1/online-devices"
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"online_devices": {
|
||||
"host_node": {
|
||||
"device_key": "/host_node",
|
||||
"namespace": "",
|
||||
"machine_name": "本地",
|
||||
"uuid": "xxx-xxx-xxx",
|
||||
"node_name": "host_node"
|
||||
}
|
||||
},
|
||||
"total_count": 1,
|
||||
"timestamp": 1732612345.123
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 2: 获取设备可用动作
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8002/api/v1/devices/host_node/actions"
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"device_id": "host_node",
|
||||
"actions": {
|
||||
"test_latency": {
|
||||
"type_name": "unilabos_msgs.action._empty_in.EmptyIn",
|
||||
"type_name_convert": "unilabos_msgs/action/_empty_in/EmptyIn",
|
||||
"action_path": "/devices/host_node/test_latency",
|
||||
"goal_info": "{}",
|
||||
"is_busy": false,
|
||||
"current_job_id": null
|
||||
},
|
||||
"create_resource": {
|
||||
"type_name": "unilabos_msgs.action._resource_create_from_outer_easy.ResourceCreateFromOuterEasy",
|
||||
"action_path": "/devices/host_node/create_resource",
|
||||
"goal_info": "{res_id: '', device_id: '', class_name: '', ...}",
|
||||
"is_busy": false,
|
||||
"current_job_id": null
|
||||
}
|
||||
},
|
||||
"action_count": 5
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
**动作状态字段说明**:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ---------------- | ----------------------------- |
|
||||
| `type_name` | 动作类型的完整名称 |
|
||||
| `action_path` | ROS2 动作路径 |
|
||||
| `goal_info` | 动作参数模板 |
|
||||
| `is_busy` | 动作是否正在执行 |
|
||||
| `current_job_id` | 当前执行的任务 ID(如果繁忙) |
|
||||
|
||||
### 步骤 3: 提交任务
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8002/api/v1/job/add" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"device_id":"host_node","action":"test_latency","action_args":{}}'
|
||||
```
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "host_node",
|
||||
"action": "test_latency",
|
||||
"action_args": {}
|
||||
}
|
||||
```
|
||||
|
||||
**请求参数说明**:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ------------- | ------ | ---- | ---------------------------------- |
|
||||
| `device_id` | string | ✓ | 目标设备 ID |
|
||||
| `action` | string | ✓ | 动作名称 |
|
||||
| `action_args` | object | ✓ | 动作参数(根据动作类型不同而变化) |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd",
|
||||
"status": 1,
|
||||
"result": {}
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
**任务状态码**:
|
||||
|
||||
| 状态码 | 含义 | 说明 |
|
||||
| ------ | --------- | ------------------------------ |
|
||||
| 0 | UNKNOWN | 未知状态 |
|
||||
| 1 | ACCEPTED | 任务已接受,等待执行 |
|
||||
| 2 | EXECUTING | 任务执行中 |
|
||||
| 3 | CANCELING | 任务取消中 |
|
||||
| 4 | SUCCEEDED | 任务成功完成 |
|
||||
| 5 | CANCELED | 任务已取消 |
|
||||
| 6 | ABORTED | 任务中止(设备繁忙或执行失败) |
|
||||
|
||||
### 步骤 4: 查询任务状态和结果
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8002/api/v1/job/b6acb586-733a-42ab-9f73-55c9a52aa8bd/status"
|
||||
```
|
||||
|
||||
**响应示例(执行中)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd",
|
||||
"status": 2,
|
||||
"result": {}
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例(执行完成)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd",
|
||||
"status": 4,
|
||||
"result": {
|
||||
"error": "",
|
||||
"suc": true,
|
||||
"return_value": {
|
||||
"avg_rtt_ms": 103.99,
|
||||
"avg_time_diff_ms": 7181.55,
|
||||
"max_time_error_ms": 7210.57,
|
||||
"task_delay_ms": -1,
|
||||
"raw_delay_ms": 33.19,
|
||||
"test_count": 5,
|
||||
"status": "success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**: 任务结果在首次查询后会被自动删除,请确保保存返回的结果数据。
|
||||
|
||||
## API 端点列表
|
||||
|
||||
### 设备相关
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
| ---------------------------------------------------------- | ---- | ---------------------- |
|
||||
| `/api/v1/online-devices` | GET | 获取在线设备列表 |
|
||||
| `/api/v1/devices` | GET | 获取设备配置 |
|
||||
| `/api/v1/devices/{device_id}/actions` | GET | 获取指定设备的可用动作 |
|
||||
| `/api/v1/devices/{device_id}/actions/{action_name}/schema` | GET | 获取动作参数 Schema |
|
||||
| `/api/v1/actions` | GET | 获取所有设备的可用动作 |
|
||||
|
||||
### 任务相关
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
| ----------------------------- | ---- | ------------------ |
|
||||
| `/api/v1/job/add` | POST | 提交新任务 |
|
||||
| `/api/v1/job/{job_id}/status` | GET | 查询任务状态和结果 |
|
||||
|
||||
### 资源相关
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
| ------------------- | ---- | ------------ |
|
||||
| `/api/v1/resources` | GET | 获取资源列表 |
|
||||
|
||||
## 常见动作示例
|
||||
|
||||
### test_latency - 延迟测试
|
||||
|
||||
测试系统延迟,无需参数。
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8002/api/v1/job/add" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"device_id":"host_node","action":"test_latency","action_args":{}}'
|
||||
```
|
||||
|
||||
### create_resource - 创建资源
|
||||
|
||||
在设备上创建新资源。
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8002/api/v1/job/add" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"device_id": "host_node",
|
||||
"action": "create_resource",
|
||||
"action_args": {
|
||||
"res_id": "my_plate",
|
||||
"device_id": "host_node",
|
||||
"class_name": "Plate",
|
||||
"parent": "deck",
|
||||
"bind_locations": {"x": 0, "y": 0, "z": 0}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 设备繁忙
|
||||
|
||||
当设备正在执行其他任务时,提交新任务会返回 `status: 6`(ABORTED):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"jobId": "xxx",
|
||||
"status": 6,
|
||||
"result": {}
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
此时应等待当前任务完成后重试,或使用 `/devices/{device_id}/actions` 检查动作的 `is_busy` 状态。
|
||||
|
||||
### 参数错误
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 2002,
|
||||
"data": { ... },
|
||||
"message": "device_id is required"
|
||||
}
|
||||
```
|
||||
|
||||
## 轮询策略
|
||||
|
||||
推荐的任务状态轮询策略:
|
||||
|
||||
```python
|
||||
import requests
|
||||
import time
|
||||
|
||||
def wait_for_job(job_id, timeout=60, interval=0.5):
|
||||
"""等待任务完成并返回结果"""
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
response = requests.get(f"http://localhost:8002/api/v1/job/{job_id}/status")
|
||||
data = response.json()["data"]
|
||||
|
||||
status = data["status"]
|
||||
if status in (4, 5, 6): # SUCCEEDED, CANCELED, ABORTED
|
||||
return data
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
raise TimeoutError(f"Job {job_id} did not complete within {timeout} seconds")
|
||||
|
||||
# 使用示例
|
||||
response = requests.post(
|
||||
"http://localhost:8002/api/v1/job/add",
|
||||
json={"device_id": "host_node", "action": "test_latency", "action_args": {}}
|
||||
)
|
||||
job_id = response.json()["data"]["jobId"]
|
||||
result = wait_for_job(job_id)
|
||||
print(result)
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [设备注册指南](add_device.md)
|
||||
- [动作定义指南](add_action.md)
|
||||
- [网络架构概述](networking_overview.md)
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 629 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 269 KiB |
594
docs/developer_guide/networking_overview.md
Normal file
@@ -0,0 +1,594 @@
|
||||
# 组网部署与主从模式配置
|
||||
|
||||
本文档介绍 Uni-Lab-OS 的组网架构、部署方式和主从模式的详细配置。
|
||||
|
||||
## 目录
|
||||
|
||||
- [架构概览](#架构概览)
|
||||
- [节点类型](#节点类型)
|
||||
- [通信机制](#通信机制)
|
||||
- [典型拓扑](#典型拓扑)
|
||||
- [主从模式配置](#主从模式配置)
|
||||
- [网络配置](#网络配置)
|
||||
- [示例:多房间部署](#示例多房间部署)
|
||||
- [故障处理](#故障处理)
|
||||
- [监控和维护](#监控和维护)
|
||||
|
||||
---
|
||||
|
||||
## 架构概览
|
||||
|
||||
Uni-Lab-OS 支持多种部署模式:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Cloud Platform/Self-hosted Platform │
|
||||
│ uni-lab.bohrium.com │
|
||||
│ (Resource Management, Task Scheduling, │
|
||||
│ Monitoring) │
|
||||
└────────────────────┬─────────────────────────┘
|
||||
│ WebSocket / HTTP
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
│ │
|
||||
┌────▼─────┐ ┌────▼─────┐
|
||||
│ Master │◄──ROS2──►│ Slave │
|
||||
│ Node │ │ Node │
|
||||
│ (Host) │ │ (Slave) │
|
||||
└────┬─────┘ └────┬─────┘
|
||||
│ │
|
||||
┌────┴────┐ ┌────┴────┐
|
||||
│ Device A│ │ Device B│
|
||||
│ Device C│ │ Device D│
|
||||
└─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 节点类型
|
||||
|
||||
### 主节点(Host Node)
|
||||
|
||||
**功能**:
|
||||
|
||||
- 创建和管理全局资源
|
||||
- 提供 host_node 服务
|
||||
- 连接云端平台
|
||||
- 协调多个从节点
|
||||
- 提供 Web 管理界面
|
||||
|
||||
**启动命令**:
|
||||
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g host_devices.json
|
||||
```
|
||||
|
||||
### 从节点(Slave Node)
|
||||
|
||||
**功能**:
|
||||
|
||||
- 管理本地设备
|
||||
- 不连接云端(可选)
|
||||
- 向主节点注册
|
||||
- 执行分配的任务
|
||||
|
||||
**启动命令**:
|
||||
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g slave_devices.json --is_slave
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 通信机制
|
||||
|
||||
### ROS2 通信
|
||||
|
||||
**用途**: 节点间实时通信
|
||||
|
||||
**通信方式**:
|
||||
|
||||
- **Topic**: 状态广播(设备状态、传感器数据)
|
||||
- **Service**: 同步请求(资源查询、配置获取)
|
||||
- **Action**: 异步任务(设备操作、长时间运行)
|
||||
|
||||
**示例**:
|
||||
|
||||
```bash
|
||||
# 查看ROS2节点
|
||||
ros2 node list
|
||||
|
||||
# 查看topic
|
||||
ros2 topic list
|
||||
|
||||
# 查看action
|
||||
ros2 action list
|
||||
```
|
||||
|
||||
### WebSocket 通信
|
||||
|
||||
**用途**: 主节点与云端通信
|
||||
|
||||
**特点**:
|
||||
|
||||
- 实时双向通信
|
||||
- 自动重连
|
||||
- 心跳保持
|
||||
|
||||
**配置**:
|
||||
|
||||
```python
|
||||
# local_config.py
|
||||
BasicConfig.ak = "your_ak"
|
||||
BasicConfig.sk = "your_sk"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 典型拓扑
|
||||
|
||||
### 单节点模式
|
||||
|
||||
**适用场景**: 小型实验室、开发测试
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ Uni-Lab Node │
|
||||
│ ┌────────────┐ │
|
||||
│ │ Device A │ │
|
||||
│ │ Device B │ │
|
||||
│ │ Device C │ │
|
||||
│ └────────────┘ │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
**优点**:
|
||||
|
||||
- 配置简单
|
||||
- 无网络延迟
|
||||
- 适合快速原型
|
||||
|
||||
**启动**:
|
||||
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g all_devices.json
|
||||
```
|
||||
|
||||
### 主从模式
|
||||
|
||||
**适用场景**: 多房间、分布式设备
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐
|
||||
│ Master Node │◄────►│ Slave Node 1 │
|
||||
│ Coordinator │ │ Liquid │
|
||||
│ Web UI │ │ Handling │
|
||||
└──────┬──────┘ └──────────────┘
|
||||
│
|
||||
│ ┌──────────────┐
|
||||
└────────────►│ Slave Node 2 │
|
||||
│ Analytical │
|
||||
│ (NMR/GC) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**优点**:
|
||||
|
||||
- 物理分隔
|
||||
- 独立故障域
|
||||
- 易于扩展
|
||||
|
||||
**适用场景**:
|
||||
|
||||
- 设备物理位置分散
|
||||
- 不同房间的设备
|
||||
- 需要独立故障域
|
||||
- 分阶段扩展系统
|
||||
|
||||
**主节点**:
|
||||
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g host.json
|
||||
```
|
||||
|
||||
**从节点**:
|
||||
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave
|
||||
unilab --ak your_ak --sk your_sk -g slave2.json --is_slave --port 8003
|
||||
```
|
||||
|
||||
### 云端集成模式
|
||||
|
||||
**适用场景**: 远程监控、多实验室协作
|
||||
|
||||
```
|
||||
Cloud Platform
|
||||
│
|
||||
┌───────┴────────┐
|
||||
│ │
|
||||
Laboratory A Laboratory B
|
||||
(Master Node) (Master Node)
|
||||
```
|
||||
|
||||
**优点**:
|
||||
|
||||
- 远程访问
|
||||
- 数据同步
|
||||
- 任务调度
|
||||
|
||||
**启动**:
|
||||
|
||||
```bash
|
||||
# 实验室A
|
||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||
|
||||
# 实验室B
|
||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 主从模式配置
|
||||
|
||||
### 主节点配置
|
||||
|
||||
#### 1. 创建主节点设备图
|
||||
|
||||
`host.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [],
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 启动主节点
|
||||
|
||||
```bash
|
||||
# 基本启动
|
||||
unilab --ak your_ak --sk your_sk -g host.json
|
||||
|
||||
# 带云端集成
|
||||
unilab --ak your_ak --sk your_sk -g host.json --upload_registry
|
||||
|
||||
# 指定端口
|
||||
unilab --ak your_ak --sk your_sk -g host.json --port 8002
|
||||
```
|
||||
|
||||
#### 3. 验证主节点
|
||||
|
||||
```bash
|
||||
# 检查ROS2节点
|
||||
ros2 node list
|
||||
# 应该看到 /host_node
|
||||
|
||||
# 检查服务
|
||||
ros2 service list | grep host_node
|
||||
|
||||
# Web界面
|
||||
# 访问 http://localhost:8002
|
||||
```
|
||||
|
||||
### 从节点配置
|
||||
|
||||
#### 1. 创建从节点设备图
|
||||
|
||||
`slave1.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "liquid_handler_1",
|
||||
"name": "液体处理工作站",
|
||||
"type": "device",
|
||||
"class": "liquid_handler",
|
||||
"config": {
|
||||
"simulation": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 启动从节点
|
||||
|
||||
```bash
|
||||
# 基本从节点启动
|
||||
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave
|
||||
|
||||
# 指定不同端口(如果多个从节点在同一台机器)
|
||||
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave --port 8003
|
||||
|
||||
# 跳过等待主节点(独立测试)
|
||||
unilab --ak your_ak --sk your_sk -g slave1.json --is_slave --slave_no_host
|
||||
```
|
||||
|
||||
#### 3. 验证从节点
|
||||
|
||||
```bash
|
||||
# 检查节点连接
|
||||
ros2 node list
|
||||
|
||||
# 检查设备状态
|
||||
ros2 topic echo /liquid_handler_1/status
|
||||
```
|
||||
|
||||
### 跨节点通信
|
||||
|
||||
#### 资源访问
|
||||
|
||||
主节点可以访问从节点的资源:
|
||||
|
||||
```bash
|
||||
# 在主节点或其他节点调用从节点设备
|
||||
ros2 action send_goal /liquid_handler_1/transfer_liquid \
|
||||
unilabos_msgs/action/TransferLiquid \
|
||||
"{source: {...}, target: {...}, volume: 100.0}"
|
||||
```
|
||||
|
||||
#### 状态监控
|
||||
|
||||
主节点监控所有从节点状态:
|
||||
|
||||
```bash
|
||||
# 订阅从节点状态
|
||||
ros2 topic echo /liquid_handler_1/status
|
||||
|
||||
# 查看所有设备状态
|
||||
ros2 topic list | grep status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 网络配置
|
||||
|
||||
### ROS2 DDS 配置
|
||||
|
||||
确保主从节点在同一网络:
|
||||
|
||||
```bash
|
||||
# 检查网络可达性
|
||||
ping <slave_node_ip>
|
||||
|
||||
# 设置ROS_DOMAIN_ID(可选,用于隔离)
|
||||
export ROS_DOMAIN_ID=42
|
||||
```
|
||||
|
||||
### 防火墙配置
|
||||
|
||||
**建议做法**:
|
||||
|
||||
为了确保 ROS2 DDS 通信正常,建议直接关闭防火墙,而不是配置特定端口。ROS2 使用动态端口范围,配置特定端口可能导致通信问题。
|
||||
|
||||
**Linux**:
|
||||
|
||||
```bash
|
||||
# 关闭防火墙
|
||||
sudo ufw disable
|
||||
|
||||
# 或者临时停止防火墙
|
||||
sudo systemctl stop ufw
|
||||
```
|
||||
|
||||
**Windows**:
|
||||
|
||||
```powershell
|
||||
# 在Windows安全中心关闭防火墙
|
||||
# 控制面板 -> 系统和安全 -> Windows Defender 防火墙 -> 启用或关闭Windows Defender防火墙
|
||||
```
|
||||
|
||||
### 验证网络连通性
|
||||
|
||||
在配置完成后,使用 ROS2 自带的 demo 节点来验证跨节点通信是否正常:
|
||||
|
||||
**在主节点机器上**(激活 unilab 环境后):
|
||||
|
||||
```bash
|
||||
# 启动talker
|
||||
ros2 run demo_nodes_cpp talker
|
||||
|
||||
# 同时在另一个终端启动listener
|
||||
ros2 run demo_nodes_cpp listener
|
||||
```
|
||||
|
||||
**在从节点机器上**(激活 unilab 环境后):
|
||||
|
||||
```bash
|
||||
# 启动talker
|
||||
ros2 run demo_nodes_cpp talker
|
||||
|
||||
# 同时在另一个终端启动listener
|
||||
ros2 run demo_nodes_cpp listener
|
||||
```
|
||||
|
||||
**注意**:必须在两台机器上**互相启动** talker 和 listener,否则可能出现只能收不能发的单向通信问题。
|
||||
|
||||
**预期结果**:
|
||||
|
||||
- 每台机器的 listener 应该能同时接收到本地和远程 talker 发送的消息
|
||||
- 如果只能看到本地消息,说明网络配置有问题
|
||||
- 如果两台机器都能互相收发消息,则组网配置正确
|
||||
|
||||
### 本地网络要求
|
||||
|
||||
**ROS2 通信**:
|
||||
|
||||
- 同一局域网或 VPN
|
||||
- 端口:默认 DDS 端口(7400-7500)
|
||||
- 组播支持(或配置 unicast)
|
||||
|
||||
**检查连通性**:
|
||||
|
||||
```bash
|
||||
# Ping测试
|
||||
ping <target_ip>
|
||||
|
||||
# ROS2节点发现
|
||||
ros2 node list
|
||||
ros2 daemon stop && ros2 daemon start
|
||||
```
|
||||
|
||||
### 云端连接
|
||||
|
||||
**要求**:
|
||||
|
||||
- HTTPS (443)
|
||||
- WebSocket 支持
|
||||
- 稳定的互联网连接
|
||||
|
||||
**测试连接**:
|
||||
|
||||
```bash
|
||||
# 测试云端连接
|
||||
curl https://uni-lab.bohrium.com/api/v1/health
|
||||
|
||||
# 测试WebSocket
|
||||
# 启动Uni-Lab后查看日志
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 示例:多房间部署
|
||||
|
||||
### 场景描述
|
||||
|
||||
- **房间 A**: 主控室,有 Web 界面
|
||||
- **房间 B**: 液体处理室
|
||||
- **房间 C**: 分析仪器室
|
||||
|
||||
### 房间 A - 主节点
|
||||
|
||||
```bash
|
||||
# host.json
|
||||
unilab --ak your_ak --sk your_sk -g host.json --port 8002
|
||||
```
|
||||
|
||||
### 房间 B - 从节点 1
|
||||
|
||||
```bash
|
||||
# liquid_handler.json
|
||||
unilab --ak your_ak --sk your_sk -g liquid_handler.json --is_slave --port 8003
|
||||
```
|
||||
|
||||
### 房间 C - 从节点 2
|
||||
|
||||
```bash
|
||||
# analytical.json
|
||||
unilab --ak your_ak --sk your_sk -g analytical.json --is_slave --port 8004
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 故障处理
|
||||
|
||||
### 节点离线
|
||||
|
||||
**检测**:
|
||||
|
||||
```bash
|
||||
ros2 node list # 查看在线节点
|
||||
```
|
||||
|
||||
**处理**:
|
||||
|
||||
1. 检查网络连接
|
||||
2. 重启节点
|
||||
3. 检查日志
|
||||
|
||||
### 从节点无法连接主节点
|
||||
|
||||
1. 检查网络:
|
||||
|
||||
```bash
|
||||
ping <host_ip>
|
||||
```
|
||||
|
||||
2. 检查 ROS_DOMAIN_ID:
|
||||
|
||||
```bash
|
||||
echo $ROS_DOMAIN_ID
|
||||
```
|
||||
|
||||
3. 使用`--slave_no_host`测试:
|
||||
```bash
|
||||
unilab --ak your_ak --sk your_sk -g slave.json --is_slave --slave_no_host
|
||||
```
|
||||
|
||||
### 通信延迟
|
||||
|
||||
**排查**:
|
||||
|
||||
```bash
|
||||
# 网络延迟
|
||||
ping <node_ip>
|
||||
|
||||
# ROS2话题延迟
|
||||
ros2 topic hz /device_status
|
||||
ros2 topic bw /device_status
|
||||
```
|
||||
|
||||
**优化**:
|
||||
|
||||
- 减少发布频率
|
||||
- 使用 QoS 配置
|
||||
- 优化网络带宽
|
||||
|
||||
### 数据同步失败
|
||||
|
||||
**检查**:
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
tail -f unilabos_data/logs/unilab.log | grep sync
|
||||
```
|
||||
|
||||
**解决**:
|
||||
|
||||
- 检查云端连接
|
||||
- 验证 AK/SK
|
||||
- 手动触发同步
|
||||
|
||||
### 资源不可见
|
||||
|
||||
检查资源注册:
|
||||
|
||||
```bash
|
||||
ros2 service call /host_node/resource_list \
|
||||
unilabos_msgs/srv/ResourceList
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 监控和维护
|
||||
|
||||
### 节点状态监控
|
||||
|
||||
```bash
|
||||
# 查看所有节点
|
||||
ros2 node list
|
||||
|
||||
# 查看话题
|
||||
ros2 topic list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [最佳实践指南](../user_guide/best_practice.md) - 完整的实验室搭建流程
|
||||
- [安装指南](../user_guide/installation.md) - 环境安装步骤
|
||||
- [启动参数详解](../user_guide/launch.md) - 启动参数说明
|
||||
- [添加设备驱动](add_device.md) - 自定义设备开发
|
||||
- [工作站架构](workstation_architecture.md) - 复杂工作站搭建
|
||||
|
||||
---
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [ROS2 网络配置](https://docs.ros.org/en/humble/Tutorials/Advanced/Networking.html)
|
||||
- [DDS 配置](https://fast-dds.docs.eprosima.com/)
|
||||
- Uni-Lab 云平台文档
|
||||
@@ -1,9 +1,23 @@
|
||||
# Uni-Lab 项目文档
|
||||
# Uni-Lab-OS 项目文档
|
||||
|
||||
欢迎来到项目文档的首页!
|
||||
Uni-Lab-OS 是一个开源的实验室自动化操作系统,提供统一的设备接口、工作流管理和分布式部署能力。
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 3
|
||||
|
||||
intro.md
|
||||
```
|
||||
|
||||
## 开发者指南
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
developer_guide/http_api.md
|
||||
developer_guide/networking_overview.md
|
||||
developer_guide/add_device.md
|
||||
developer_guide/add_action.md
|
||||
developer_guide/add_registry.md
|
||||
developer_guide/add_yaml.md
|
||||
developer_guide/action_includes.md
|
||||
```
|
||||
|
||||
@@ -10,29 +10,51 @@ concepts/01-communication-instruction.md
|
||||
concepts/02-topology-and-chemputer-compile.md
|
||||
```
|
||||
|
||||
## **用户指南**
|
||||
## 用户指南
|
||||
|
||||
本指南将带你了解如何使用项目的功能。
|
||||
快速上手、系统配置与使用说明。
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
user_guide/best_practice.md
|
||||
user_guide/installation.md
|
||||
user_guide/configuration.md
|
||||
user_guide/launch.md
|
||||
user_guide/graph_files.md
|
||||
boot_examples/index.md
|
||||
```
|
||||
|
||||
## 进阶配置
|
||||
|
||||
高级配置和系统管理。
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
advanced_usage/configuration.md
|
||||
advanced_usage/working_directory.md
|
||||
```
|
||||
|
||||
## 开发者指南
|
||||
|
||||
设备开发、系统扩展与架构说明。
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
developer_guide/device_driver
|
||||
developer_guide/add_device
|
||||
developer_guide/add_action
|
||||
developer_guide/actions
|
||||
developer_guide/add_protocol
|
||||
developer_guide/networking_overview.md
|
||||
developer_guide/add_device.md
|
||||
developer_guide/add_old_device.md
|
||||
developer_guide/add_registry.md
|
||||
developer_guide/add_yaml.md
|
||||
developer_guide/add_action.md
|
||||
developer_guide/actions.md
|
||||
developer_guide/action_includes.md
|
||||
developer_guide/add_protocol.md
|
||||
developer_guide/examples/workstation_architecture.md
|
||||
developer_guide/examples/materials_construction_guide.md
|
||||
developer_guide/examples/materials_tutorial.md
|
||||
developer_guide/examples/battery_plc_workstation.md
|
||||
```
|
||||
|
||||
## 接口文档
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
sphinx>=7.0.0
|
||||
sphinx-rtd-theme>=2.0.0
|
||||
myst-parser>=2.0.0
|
||||
sphinxcontrib-mermaid
|
||||
|
||||
# 用于支持Jupyter notebook文档
|
||||
myst-nb>=1.0.0
|
||||
|
||||
1837
docs/user_guide/best_practice.md
Normal file
@@ -1,109 +0,0 @@
|
||||
# Uni-Lab 配置指南
|
||||
|
||||
Uni-Lab支持通过Python配置文件进行灵活的系统配置。本指南将帮助您理解配置选项并设置您的Uni-Lab环境。
|
||||
|
||||
## 配置文件格式
|
||||
|
||||
Uni-Lab支持Python格式的配置文件,它比YAML或JSON提供更多的灵活性,包括支持注释、条件逻辑和复杂数据结构。
|
||||
|
||||
### 基本配置示例
|
||||
|
||||
一个典型的配置文件包含以下部分:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
"""Uni-Lab 配置文件"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
# 配置类定义
|
||||
|
||||
class MQConfig:
|
||||
"""MQTT 配置类"""
|
||||
lab_id: str = "YOUR_LAB_ID"
|
||||
# 更多配置...
|
||||
|
||||
# 其他配置类...
|
||||
```
|
||||
|
||||
## 配置选项说明
|
||||
|
||||
### 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
|
||||
|
||||
class ROSConfig:
|
||||
"""ROS模块配置"""
|
||||
modules = [
|
||||
"std_msgs.msg",
|
||||
"geometry_msgs.msg",
|
||||
"control_msgs.msg",
|
||||
"control_msgs.action",
|
||||
"nav2_msgs.action",
|
||||
"unilabos_msgs.msg",
|
||||
"unilabos_msgs.action",
|
||||
]
|
||||
```
|
||||
|
||||
您可以根据需要添加其他ROS模块。
|
||||
|
||||
### 其他配置选项
|
||||
|
||||
- **OSSUploadConfig**: 对象存储上传配置
|
||||
|
||||
## 如何使用配置文件
|
||||
|
||||
启动Uni-Lab时通过`--config`参数指定配置文件路径:
|
||||
|
||||
```bash
|
||||
unilab --config path/to/your/config.py
|
||||
```
|
||||
|
||||
如果您不涉及多环境开发,可以在unilabos的安装路径中手动添加local_config.py的文件
|
||||
|
||||
# 启动Uni-Lab
|
||||
python -m unilabos.app.main --config path/to/your/config.py
|
||||
```
|
||||
860
docs/user_guide/graph_files.md
Normal file
@@ -0,0 +1,860 @@
|
||||
# 设备图文件说明
|
||||
|
||||
设备图文件定义了实验室中所有设备、资源及其连接关系。本文档说明如何创建和使用设备图文件。
|
||||
|
||||
## 概述
|
||||
|
||||
设备图文件采用 JSON 格式,节点定义基于 **`ResourceDict`** 标准模型(定义在 `unilabos.ros.nodes.resource_tracker`)。系统会自动处理旧格式并转换为标准格式,确保向后兼容性。
|
||||
|
||||
**核心概念**:
|
||||
|
||||
- **Nodes(节点)**: 代表设备或资源,通过 `parent` 字段建立层级关系
|
||||
- **Links(连接)**: 可选的连接关系定义,用于展示设备间的物理或通信连接
|
||||
- **UUID**: 全局唯一标识符,用于跨系统的资源追踪
|
||||
- **自动转换**: 旧格式会通过 `ResourceDictInstance.get_resource_instance_from_dict()` 自动转换
|
||||
|
||||
## 文件格式
|
||||
|
||||
Uni-Lab 支持两种格式的设备图文件:
|
||||
|
||||
### JSON 格式(推荐)
|
||||
|
||||
**优点**:
|
||||
|
||||
- 易于编辑和阅读
|
||||
- 支持注释(使用预处理)
|
||||
- 与 Web 界面完全兼容
|
||||
- 便于版本控制
|
||||
|
||||
**示例**: `workshop1.json`
|
||||
|
||||
### GraphML 格式
|
||||
|
||||
**优点**:
|
||||
|
||||
- 可用图形化工具编辑(如 yEd)
|
||||
- 适合复杂拓扑可视化
|
||||
|
||||
**示例**: `setup.graphml`
|
||||
|
||||
## JSON 文件结构
|
||||
|
||||
一个完整的 JSON 设备图文件包含两个主要部分:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
/* 设备和资源节点 */
|
||||
],
|
||||
"links": [
|
||||
/* 连接关系(可选)*/
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Nodes(节点)
|
||||
|
||||
每个节点代表一个设备或资源。节点的定义遵循 `ResourceDict` 标准模型:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "liquid_handler_1",
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "液体处理工作站",
|
||||
"type": "device",
|
||||
"class": "liquid_handler",
|
||||
"config": {
|
||||
"port": "/dev/ttyUSB0",
|
||||
"baudrate": 9600
|
||||
},
|
||||
"data": {},
|
||||
"position": {
|
||||
"x": 100,
|
||||
"y": 200
|
||||
},
|
||||
"parent": null
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明(基于 ResourceDict 标准定义)**:
|
||||
|
||||
| 字段 | 必需 | 说明 | 示例 | 默认值 |
|
||||
| ------------- | ---- | ------------------------ | ---------------------------------------------------- | -------- |
|
||||
| `id` | ✓ | 唯一标识符 | `"pump_1"` | - |
|
||||
| `uuid` | | 全局唯一标识符 (UUID) | `"550e8400-e29b-41d4-a716-446655440000"` | 自动生成 |
|
||||
| `name` | ✓ | 显示名称 | `"主反应泵"` | - |
|
||||
| `type` | ✓ | 节点类型 | `"device"`, `"resource"`, `"container"`, `"deck"` 等 | - |
|
||||
| `class` | ✓ | 设备/资源类别 | `"liquid_handler"`, `"syringepump.runze"` | `""` |
|
||||
| `config` | | Python 类的初始化参数 | `{"port": "COM3"}` | `{}` |
|
||||
| `data` | | 资源的运行状态数据 | `{"status": "Idle", "position": 0.0}` | `{}` |
|
||||
| `position` | | 在图中的位置 | `{"x": 100, "y": 200}` 或完整的 pose 结构 | - |
|
||||
| `pose` | | 完整的 3D 位置信息 | 参见下文 | - |
|
||||
| `parent` | | 父节点 ID | `"deck_1"` | `null` |
|
||||
| `parent_uuid` | | 父节点 UUID | `"550e8400-..."` | `null` |
|
||||
| `children` | | 子节点 ID 列表(旧格式) | `["child1", "child2"]` | - |
|
||||
| `description` | | 资源描述 | `"用于精确控制试剂A的加料速率"` | `""` |
|
||||
| `schema` | | 资源 schema 定义 | `{}` | `{}` |
|
||||
| `model` | | 资源 3D 模型信息 | `{}` | `{}` |
|
||||
| `icon` | | 资源图标 | `"pump.webp"` | `""` |
|
||||
| `extra` | | 额外的自定义数据 | `{"custom_field": "value"}` | `{}` |
|
||||
|
||||
### Position 和 Pose(位置信息)
|
||||
|
||||
**简单格式(旧格式,兼容)**:
|
||||
|
||||
```json
|
||||
"position": {
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"z": 0
|
||||
}
|
||||
```
|
||||
|
||||
**完整格式(推荐)**:
|
||||
|
||||
```json
|
||||
"pose": {
|
||||
"size": {
|
||||
"width": 127.76,
|
||||
"height": 85.48,
|
||||
"depth": 10.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
},
|
||||
"layout": "x-y",
|
||||
"position": {
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"z": 0
|
||||
},
|
||||
"position3d": {
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"z": 0
|
||||
},
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"cross_section_type": "rectangle"
|
||||
}
|
||||
```
|
||||
|
||||
### Links(连接)
|
||||
|
||||
定义节点之间的连接关系(可选,主要用于物理连接或通信关系的可视化):
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "pump_1",
|
||||
"target": "reactor_1",
|
||||
"sourceHandle": "output",
|
||||
"targetHandle": "input",
|
||||
"type": "physical"
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 字段 | 必需 | 说明 | 示例 |
|
||||
| -------------- | ---- | ---------------- | ---------------------------------------- |
|
||||
| `source` | ✓ | 源节点 ID | `"pump_1"` |
|
||||
| `target` | ✓ | 目标节点 ID | `"reactor_1"` |
|
||||
| `sourceHandle` | | 源节点的连接点 | `"output"` |
|
||||
| `targetHandle` | | 目标节点的连接点 | `"input"` |
|
||||
| `type` | | 连接类型 | `"physical"`, `"communication"` |
|
||||
| `port` | | 端口映射信息 | `{"source": "port1", "target": "port2"}` |
|
||||
|
||||
**注意**: Links 主要用于图形化展示和文档说明,父子关系通过 `parent` 字段定义,不依赖 links。
|
||||
|
||||
## 完整示例
|
||||
|
||||
### 示例 1:液体处理工作站(PRCXI9300)
|
||||
|
||||
这是一个真实的液体处理工作站配置,包含设备、工作台和多个板资源。
|
||||
|
||||
**文件位置**: `test/experiments/prcxi_9300.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "PRCXI9300",
|
||||
"name": "PRCXI9300",
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "liquid_handler.prcxi",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"deck": {
|
||||
"_resource_child_name": "PRCXI_Deck_9300",
|
||||
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck"
|
||||
},
|
||||
"host": "10.181.214.132",
|
||||
"port": 9999,
|
||||
"timeout": 10.0,
|
||||
"axis": "Left",
|
||||
"channel_num": 8,
|
||||
"setup": false,
|
||||
"debug": true,
|
||||
"simulator": true,
|
||||
"matrix_id": "71593"
|
||||
},
|
||||
"data": {},
|
||||
"children": ["PRCXI_Deck_9300"]
|
||||
},
|
||||
{
|
||||
"id": "PRCXI_Deck_9300",
|
||||
"name": "PRCXI_Deck_9300",
|
||||
"parent": "PRCXI9300",
|
||||
"type": "deck",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Deck",
|
||||
"size_x": 100,
|
||||
"size_y": 100,
|
||||
"size_z": 100,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "deck"
|
||||
},
|
||||
"data": {},
|
||||
"children": [
|
||||
"RackT1",
|
||||
"PlateT2",
|
||||
"trash",
|
||||
"PlateT4",
|
||||
"PlateT5",
|
||||
"PlateT6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "RackT1",
|
||||
"name": "RackT1",
|
||||
"parent": "PRCXI_Deck_9300",
|
||||
"type": "tip_rack",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "TipRack",
|
||||
"size_x": 127.76,
|
||||
"size_y": 85.48,
|
||||
"size_z": 100
|
||||
},
|
||||
"data": {},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
|
||||
- 使用 `parent` 字段建立层级关系(PRCXI9300 → Deck → Rack/Plate)
|
||||
- 使用 `children` 字段(旧格式)列出子节点
|
||||
- `config` 中包含设备特定的连接参数
|
||||
- `data` 存储运行时状态
|
||||
- `position` 使用简单的 x/y/z 坐标
|
||||
|
||||
### 示例 2:有机合成工作站(带 Links)
|
||||
|
||||
这是一个格林纳德反应的流动化学工作站配置,展示了完整的设备连接和通信关系。
|
||||
|
||||
**文件位置**: `test/experiments/Grignard_flow_batchreact_single_pumpvalve.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "YugongStation",
|
||||
"name": "愚公常量合成工作站",
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "workstation",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"protocol_type": [
|
||||
"PumpTransferProtocol",
|
||||
"CleanProtocol",
|
||||
"SeparateProtocol",
|
||||
"EvaporateProtocol"
|
||||
]
|
||||
},
|
||||
"data": {},
|
||||
"children": [
|
||||
"serial_pump",
|
||||
"pump_reagents",
|
||||
"flask_CH2Cl2",
|
||||
"reactor",
|
||||
"pump_workup",
|
||||
"separator_controller",
|
||||
"flask_separator",
|
||||
"rotavap",
|
||||
"column"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "serial_pump",
|
||||
"name": "serial_pump",
|
||||
"parent": "YugongStation",
|
||||
"type": "device",
|
||||
"class": "serial",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"port": "COM7",
|
||||
"baudrate": 9600
|
||||
},
|
||||
"data": {},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "pump_reagents",
|
||||
"name": "pump_reagents",
|
||||
"parent": "YugongStation",
|
||||
"type": "device",
|
||||
"class": "syringepump.runze",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"port": "/devices/PumpBackbone/Serial/serialwrite",
|
||||
"address": "1",
|
||||
"max_volume": 25.0
|
||||
},
|
||||
"data": {
|
||||
"max_velocity": 1.0,
|
||||
"position": 0.0,
|
||||
"status": "Idle",
|
||||
"valve_position": "0"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "reactor",
|
||||
"name": "reactor",
|
||||
"parent": "YugongStation",
|
||||
"type": "container",
|
||||
"class": null,
|
||||
"position": {
|
||||
"x": 430.4087301587302,
|
||||
"y": 428,
|
||||
"z": 0
|
||||
},
|
||||
"config": {},
|
||||
"data": {},
|
||||
"children": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"source": "pump_reagents",
|
||||
"target": "serial_pump",
|
||||
"type": "communication",
|
||||
"port": {
|
||||
"pump_reagents": "port",
|
||||
"serial_pump": "port"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "pump_workup",
|
||||
"target": "serial_pump",
|
||||
"type": "communication",
|
||||
"port": {
|
||||
"pump_workup": "port",
|
||||
"serial_pump": "port"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
|
||||
- 多级设备层次:工作站包含多个子设备和容器
|
||||
- `links` 定义通信关系(泵通过串口连接)
|
||||
- `data` 字段存储设备状态(如泵的位置、速度等)
|
||||
- `class` 可以使用点号分层(如 `"syringepump.runze"`)
|
||||
- 容器的 `class` 可以为 `null`
|
||||
|
||||
## 格式兼容性和转换
|
||||
|
||||
### 旧格式自动转换
|
||||
|
||||
Uni-Lab 使用 `ResourceDictInstance.get_resource_instance_from_dict()` 方法自动处理旧格式的节点数据,确保向后兼容性。
|
||||
|
||||
**自动转换规则**:
|
||||
|
||||
1. **自动生成缺失字段**:
|
||||
|
||||
```python
|
||||
# 如果缺少 id,使用 name 作为 id
|
||||
if "id" not in content:
|
||||
content["id"] = content["name"]
|
||||
|
||||
# 如果缺少 uuid,自动生成
|
||||
if "uuid" not in content:
|
||||
content["uuid"] = str(uuid.uuid4())
|
||||
```
|
||||
|
||||
2. **Position 格式转换**:
|
||||
|
||||
```python
|
||||
# 旧格式:简单的 x/y 坐标
|
||||
"position": {"x": 100, "y": 200}
|
||||
|
||||
# 自动转换为新格式
|
||||
"position": {
|
||||
"position": {"x": 100, "y": 200}
|
||||
}
|
||||
```
|
||||
|
||||
3. **默认值填充**:
|
||||
|
||||
```python
|
||||
# 自动填充空字段
|
||||
if not content.get("class"):
|
||||
content["class"] = ""
|
||||
if not content.get("config"):
|
||||
content["config"] = {}
|
||||
if not content.get("data"):
|
||||
content["data"] = {}
|
||||
if not content.get("extra"):
|
||||
content["extra"] = {}
|
||||
```
|
||||
|
||||
4. **Pose 字段同步**:
|
||||
```python
|
||||
# 如果没有 pose,使用 position
|
||||
if "pose" not in content:
|
||||
content["pose"] = content.get("position", {})
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```python
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceDictInstance
|
||||
|
||||
# 旧格式节点
|
||||
old_format_node = {
|
||||
"name": "pump_1",
|
||||
"type": "device",
|
||||
"class": "syringepump",
|
||||
"position": {"x": 100, "y": 200}
|
||||
}
|
||||
|
||||
# 自动转换为标准格式
|
||||
instance = ResourceDictInstance.get_resource_instance_from_dict(old_format_node)
|
||||
|
||||
# 访问标准化后的数据
|
||||
print(instance.res_content.id) # "pump_1"
|
||||
print(instance.res_content.uuid) # 自动生成的 UUID
|
||||
print(instance.res_content.config) # {}
|
||||
print(instance.res_content.data) # {}
|
||||
```
|
||||
|
||||
### 格式迁移建议
|
||||
|
||||
虽然系统会自动处理旧格式,但建议在新文件中使用完整的标准格式:
|
||||
|
||||
| 字段 | 旧格式(兼容) | 新格式(推荐) |
|
||||
| ------ | ---------------------------------- | ------------------------------------------------ |
|
||||
| 标识符 | 仅 `id` 或仅 `name` | `id` + `uuid` |
|
||||
| 位置 | `"position": {"x": 100, "y": 200}` | 完整的 `pose` 结构 |
|
||||
| 父节点 | `"parent": "parent_id"` | `"parent": "parent_id"` + `"parent_uuid": "..."` |
|
||||
| 配置 | 可省略 | 显式设置为 `{}` |
|
||||
| 数据 | 可省略 | 显式设置为 `{}` |
|
||||
|
||||
## 节点类型详解
|
||||
|
||||
### Device 节点
|
||||
|
||||
设备节点代表实际的硬件设备:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "device_id",
|
||||
"name": "设备名称",
|
||||
"type": "device",
|
||||
"class": "设备类别",
|
||||
"parent": null,
|
||||
"config": {
|
||||
"port": "COM3"
|
||||
},
|
||||
"data": {},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
**常见设备类别**:
|
||||
|
||||
- `liquid_handler`: 液体处理工作站
|
||||
- `liquid_handler.prcxi`: PRCXI 液体处理工作站
|
||||
- `syringepump`: 注射泵
|
||||
- `syringepump.runze`: 润泽注射泵
|
||||
- `heaterstirrer`: 加热搅拌器
|
||||
- `balance`: 天平
|
||||
- `reactor_vessel`: 反应釜
|
||||
- `serial`: 串口通信设备
|
||||
- `workstation`: 自动化工作站
|
||||
|
||||
### Resource 节点
|
||||
|
||||
资源节点代表物料容器、载具等:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "resource_id",
|
||||
"name": "资源名称",
|
||||
"type": "resource",
|
||||
"class": "资源类别",
|
||||
"parent": "父节点ID",
|
||||
"config": {
|
||||
"size_x": 127.76,
|
||||
"size_y": 85.48,
|
||||
"size_z": 100
|
||||
},
|
||||
"data": {},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
**常见资源类型**:
|
||||
|
||||
- `deck`: 工作台/甲板
|
||||
- `plate`: 板(96 孔板等)
|
||||
- `tip_rack`: 枪头架
|
||||
- `tube`: 试管
|
||||
- `container`: 容器
|
||||
- `well`: 孔位
|
||||
- `bottle_carrier`: 瓶架
|
||||
|
||||
## Handle(连接点)
|
||||
|
||||
每个设备和资源可以有多个连接点(handles),用于定义可以连接的接口。
|
||||
|
||||
### 查看可用 handles
|
||||
|
||||
设备和资源的可用 handles 定义在注册表中:
|
||||
|
||||
```yaml
|
||||
# 设备注册表示例
|
||||
liquid_handler:
|
||||
handles:
|
||||
- handler_key: pipette
|
||||
io_type: source
|
||||
- handler_key: deck
|
||||
io_type: target
|
||||
```
|
||||
|
||||
### 常见 handles
|
||||
|
||||
| 设备类型 | Source Handles | Target Handles |
|
||||
| ---------- | -------------- | -------------- |
|
||||
| 泵 | output | input |
|
||||
| 反应釜 | output, vessel | input |
|
||||
| 液体处理器 | pipette | deck |
|
||||
| 板 | wells | access |
|
||||
|
||||
## 使用 Web 界面创建图文件
|
||||
|
||||
Uni-Lab 提供 Web 界面来可视化创建和编辑设备图:
|
||||
|
||||
### 1. 启动 Uni-Lab
|
||||
|
||||
```bash
|
||||
unilab
|
||||
```
|
||||
|
||||
### 2. 访问 Web 界面
|
||||
|
||||
打开浏览器访问 `http://localhost:8002`
|
||||
|
||||
### 3. 图形化编辑
|
||||
|
||||
- 拖拽添加设备和资源
|
||||
- 连线建立连接关系
|
||||
- 编辑节点属性
|
||||
- 保存为 JSON 文件
|
||||
|
||||
### 4. 导出图文件
|
||||
|
||||
点击"导出"按钮,下载 JSON 文件到本地。
|
||||
|
||||
## 从云端获取图文件
|
||||
|
||||
如果不指定`-g`参数,Uni-Lab 会自动从云端获取:
|
||||
|
||||
```bash
|
||||
# 使用云端配置
|
||||
unilab
|
||||
|
||||
# 日志会显示:
|
||||
# [INFO] 未指定设备加载文件路径,尝试从HTTP获取...
|
||||
# [INFO] 联网获取设备加载文件成功
|
||||
```
|
||||
|
||||
**云端图文件管理**:
|
||||
|
||||
1. 登录 https://uni-lab.bohrium.com
|
||||
2. 进入"设备配置"
|
||||
3. 创建或编辑配置
|
||||
4. 保存到云端
|
||||
|
||||
本地启动时会自动同步最新配置。
|
||||
|
||||
## 调试图文件
|
||||
|
||||
### 验证 JSON 格式
|
||||
|
||||
```bash
|
||||
# 使用Python验证
|
||||
python -c "import json; json.load(open('workshop1.json'))"
|
||||
|
||||
# 使用在线工具
|
||||
# https://jsonlint.com/
|
||||
```
|
||||
|
||||
### 检查节点引用
|
||||
|
||||
确保:
|
||||
|
||||
- 所有`links`中的`source`和`target`都存在于`nodes`中
|
||||
- `parent`字段指向的节点存在
|
||||
- `class`字段对应的设备/资源在注册表中存在
|
||||
|
||||
### 启动时验证
|
||||
|
||||
```bash
|
||||
# Uni-Lab启动时会验证图文件
|
||||
unilab -g workshop1.json
|
||||
|
||||
# 查看日志中的错误或警告
|
||||
# [ERROR] 节点 xxx 的source端点 yyy 不存在
|
||||
# [WARNING] 节点 zzz missing 'name', defaulting to ...
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 命名规范
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "pump_reagent_1", // 小写+下划线,描述性
|
||||
"name": "试剂进料泵A", // 中文显示名称
|
||||
"class": "syringepump" // 使用注册表中的精确名称
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 层级组织
|
||||
|
||||
```
|
||||
host_node (主节点)
|
||||
└── liquid_handler_1 (设备)
|
||||
└── deck_1 (资源)
|
||||
├── tiprack_1 (资源)
|
||||
├── plate_1 (资源)
|
||||
└── reservoir_1 (资源)
|
||||
```
|
||||
|
||||
### 3. 配置分离
|
||||
|
||||
将设备特定配置放在`config`中:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "pump_1",
|
||||
"class": "syringepump",
|
||||
"config": {
|
||||
"port": "COM3", // 设备特定
|
||||
"max_flow_rate": 10, // 设备特定
|
||||
"volume": 50 // 设备特定
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 版本控制
|
||||
|
||||
```bash
|
||||
# 使用Git管理图文件
|
||||
git add workshop1.json
|
||||
git commit -m "Add new liquid handler configuration"
|
||||
|
||||
# 使用有意义的文件名
|
||||
workshop_v1.json
|
||||
workshop_production.json
|
||||
workshop_test.json
|
||||
```
|
||||
|
||||
### 5. 注释(通过描述字段)
|
||||
|
||||
虽然 JSON 不支持注释,但可以使用`description`字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "pump_1",
|
||||
"name": "进料泵",
|
||||
"description": "用于精确控制试剂A的加料速率,最大流速10mL/min",
|
||||
"class": "syringepump"
|
||||
}
|
||||
```
|
||||
|
||||
## 示例文件位置
|
||||
|
||||
Uni-Lab 在安装时已预置了 **40+ 个真实的设备图文件示例**,位于 `unilabos/test/experiments/` 目录。这些都是真实项目中使用的配置文件,可以直接使用或作为参考。
|
||||
|
||||
### 📁 主要示例文件
|
||||
|
||||
```
|
||||
test/experiments/
|
||||
├── workshop.json # 综合工作台(推荐新手)
|
||||
├── empty_devices.json # 空设备配置(最小化)
|
||||
├── prcxi_9300.json # PRCXI液体处理工作站(本文示例1)
|
||||
├── prcxi_9320.json # PRCXI 9320工作站
|
||||
├── biomek.json # Biomek液体处理工作站
|
||||
├── Grignard_flow_batchreact_single_pumpvalve.json # 格林纳德反应工作站(本文示例2)
|
||||
├── dispensing_station_bioyond.json # Bioyond配液站
|
||||
├── reaction_station_bioyond.json # Bioyond反应站
|
||||
├── HPLC.json # HPLC分析系统
|
||||
├── plr_test.json # PyLabRobot测试配置
|
||||
├── lidocaine-graph.json # 利多卡因合成工作站
|
||||
├── opcua_example.json # OPC UA设备集成示例
|
||||
│
|
||||
├── mock_devices/ # 虚拟设备(用于离线测试)
|
||||
│ ├── mock_all.json # 完整虚拟设备集
|
||||
│ ├── mock_pump.json # 虚拟泵
|
||||
│ ├── mock_stirrer.json # 虚拟搅拌器
|
||||
│ ├── mock_heater.json # 虚拟加热器
|
||||
│ └── ... # 更多虚拟设备
|
||||
│
|
||||
├── Protocol_Test_Station/ # 协议测试工作站
|
||||
│ ├── pumptransfer_test_station.json # 泵转移协议测试
|
||||
│ ├── heatchill_protocol_test_station.json # 加热冷却协议测试
|
||||
│ ├── filter_protocol_test_station.json # 过滤协议测试
|
||||
│ └── ... # 更多协议测试
|
||||
│
|
||||
└── comprehensive_protocol/ # 综合协议示例
|
||||
├── comprehensive_station.json # 综合工作站
|
||||
└── comprehensive_slim.json # 精简版综合工作站
|
||||
```
|
||||
|
||||
### 🚀 快速使用
|
||||
|
||||
无需下载或创建,直接使用 `-g` 参数指定路径:
|
||||
|
||||
```bash
|
||||
# 使用简单工作台(推荐新手)
|
||||
unilab --ak your_ak --sk your_sk -g test/experiments/workshop.json
|
||||
|
||||
# 使用虚拟设备(无需真实硬件)
|
||||
unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
||||
|
||||
# 使用 PRCXI 液体处理工作站
|
||||
unilab --ak your_ak --sk your_sk -g test/experiments/prcxi_9300.json
|
||||
|
||||
# 使用格林纳德反应工作站
|
||||
unilab --ak your_ak --sk your_sk -g test/experiments/Grignard_flow_batchreact_single_pumpvalve.json
|
||||
```
|
||||
|
||||
### 📚 文件分类
|
||||
|
||||
| 类别 | 说明 | 文件数量 |
|
||||
| ------------ | ------------------------ | -------- |
|
||||
| **主工作站** | 完整的实验工作站配置 | 15+ |
|
||||
| **虚拟设备** | 用于开发测试的 mock 设备 | 10+ |
|
||||
| **协议测试** | 各种实验协议的测试配置 | 12+ |
|
||||
| **综合示例** | 包含多种协议的综合工作站 | 3+ |
|
||||
|
||||
这些文件展示了不同场景下的设备图配置,涵盖液体处理、有机合成、分析检测等多个领域,是学习和创建自己配置的绝佳参考。
|
||||
|
||||
## 快速参考:ResourceDict 完整字段列表
|
||||
|
||||
基于 `unilabos.ros.nodes.resource_tracker.ResourceDict` 的完整字段定义:
|
||||
|
||||
```python
|
||||
class ResourceDict(BaseModel):
|
||||
# === 基础标识 ===
|
||||
id: str # 资源ID(必需)
|
||||
uuid: str # 全局唯一标识符(自动生成)
|
||||
name: str # 显示名称(必需)
|
||||
|
||||
# === 类型和分类 ===
|
||||
type: Union[Literal["device"], str] # 节点类型(必需)
|
||||
klass: str # 资源类别(alias="class",必需)
|
||||
|
||||
# === 层级关系 ===
|
||||
parent: Optional[ResourceDict] # 父资源对象(不序列化)
|
||||
parent_uuid: Optional[str] # 父资源UUID
|
||||
|
||||
# === 位置和姿态 ===
|
||||
position: ResourceDictPosition # 位置信息
|
||||
pose: ResourceDictPosition # 姿态信息(推荐使用)
|
||||
|
||||
# === 配置和数据 ===
|
||||
config: Dict[str, Any] # 设备配置参数
|
||||
data: Dict[str, Any] # 运行时状态数据
|
||||
extra: Dict[str, Any] # 额外自定义数据
|
||||
|
||||
# === 元数据 ===
|
||||
description: str # 资源描述
|
||||
resource_schema: Dict[str, Any] # schema定义(alias="schema")
|
||||
model: Dict[str, Any] # 3D模型信息
|
||||
icon: str # 图标路径
|
||||
```
|
||||
|
||||
**Position/Pose 结构**:
|
||||
|
||||
```python
|
||||
class ResourceDictPosition(BaseModel):
|
||||
size: ResourceDictPositionSize # width, height, depth
|
||||
scale: ResourceDictPositionScale # x, y, z
|
||||
layout: Literal["2d", "x-y", "z-y", "x-z"]
|
||||
position: ResourceDictPositionObject # x, y, z
|
||||
position3d: ResourceDictPositionObject # x, y, z
|
||||
rotation: ResourceDictPositionObject # x, y, z
|
||||
cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"]
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- {doc}`../boot_examples/index` - 查看完整启动示例
|
||||
- {doc}`../developer_guide/add_device` - 了解如何添加新设备
|
||||
- {doc}`06_troubleshooting` - 图文件相关问题排查
|
||||
- 源码参考: `unilabos/ros/nodes/resource_tracker.py` - ResourceDict 标准定义
|
||||
|
||||
## 获取帮助
|
||||
|
||||
- 在 Web 界面中使用模板创建
|
||||
- 参考示例文件:`test/experiments/` 目录
|
||||
- 查看 ResourceDict 源码了解完整定义
|
||||
- [GitHub 讨论区](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
|
||||
BIN
docs/user_guide/image/copy_aksk.gif
Normal file
|
After Width: | Height: | Size: 526 KiB |
BIN
docs/user_guide/image/creatworkfollow.gif
Normal file
|
After Width: | Height: | Size: 327 KiB |
BIN
docs/user_guide/image/links.png
Normal file
|
After Width: | Height: | Size: 275 KiB |
BIN
docs/user_guide/image/linksandrun.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
docs/user_guide/image/material.png
Normal file
|
After Width: | Height: | Size: 581 KiB |
BIN
docs/user_guide/image/new.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
docs/user_guide/image/test_latency_result.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/user_guide/image/test_latency_running.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/user_guide/image/test_latency_select_device.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
@@ -1,24 +1,555 @@
|
||||
# **Uni-Lab 安装**
|
||||
# Uni-Lab-OS 安装指南
|
||||
|
||||
请先 `git clone` 本仓库,随后按照以下步骤安装项目:
|
||||
本指南提供 Uni-Lab-OS 的完整安装说明,涵盖从快速一键安装到完整开发环境配置的所有方式。
|
||||
|
||||
`Uni-Lab` 建议您采用 `mamba` 管理环境。若需从头建立 `Uni-Lab` 的运行依赖环境,请执行
|
||||
## 系统要求
|
||||
|
||||
```shell
|
||||
mamba env create -f unilabos-<YOUR_OS>.yaml
|
||||
- **操作系统**: Windows 10/11, Linux (Ubuntu 20.04+), macOS (10.15+)
|
||||
- **内存**: 最小 4GB,推荐 8GB 以上
|
||||
- **磁盘空间**: 至少 10GB 可用空间
|
||||
- **网络**: 稳定的互联网连接(用于下载软件包)
|
||||
- **其他**:
|
||||
- 已安装 Conda/Miniconda/Miniforge/Mamba
|
||||
- 开发者需要 Git 和基本的 Python 开发知识
|
||||
- 自定义 msgs 需要 GitHub 账号
|
||||
|
||||
## 安装方式选择
|
||||
|
||||
根据您的使用场景,选择合适的安装方式:
|
||||
|
||||
| 安装方式 | 适用人群 | 特点 | 安装时间 |
|
||||
| ---------------------- | -------------------- | ------------------------------ | ---------------------------- |
|
||||
| **方式一:一键安装** | 实验室用户、快速体验 | 预打包环境,离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) |
|
||||
| **方式二:手动安装** | 标准用户、生产环境 | 灵活配置,版本可控 | 10-20 分钟 |
|
||||
| **方式三:开发者安装** | 开发者、需要修改源码 | 可编辑模式,支持自定义 msgs | 20-30 分钟 |
|
||||
|
||||
---
|
||||
|
||||
## 方式一:一键安装(推荐新用户)
|
||||
|
||||
使用预打包的 conda 环境,最快速的安装方法。
|
||||
|
||||
### 前置条件
|
||||
|
||||
确保已安装 Conda/Miniconda/Miniforge/Mamba。
|
||||
|
||||
### 安装步骤
|
||||
|
||||
#### 第一步:下载预打包环境
|
||||
|
||||
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
|
||||
|
||||
2. 选择最新的成功构建记录(绿色勾号 ✓)
|
||||
|
||||
3. 在页面底部的 "Artifacts" 部分,下载对应你操作系统的压缩包:
|
||||
- Windows: `unilab-pack-win-64-{branch}.zip`
|
||||
- macOS (Intel): `unilab-pack-osx-64-{branch}.tar.gz`
|
||||
- macOS (Apple Silicon): `unilab-pack-osx-arm64-{branch}.tar.gz`
|
||||
- Linux: `unilab-pack-linux-64-{branch}.tar.gz`
|
||||
|
||||
#### 第二步:解压并运行安装脚本
|
||||
|
||||
**Windows**:
|
||||
|
||||
```batch
|
||||
REM 使用 Windows 资源管理器解压下载的 zip 文件
|
||||
REM 或使用命令行:
|
||||
tar -xzf unilab-pack-win-64-dev.zip
|
||||
|
||||
REM 进入解压后的目录
|
||||
cd unilab-pack-win-64-dev
|
||||
|
||||
REM 双击运行 install_unilab.bat
|
||||
REM 或在命令行中执行:
|
||||
install_unilab.bat
|
||||
```
|
||||
|
||||
**macOS**:
|
||||
|
||||
```bash
|
||||
# 解压下载的压缩包
|
||||
tar -xzf unilab-pack-osx-arm64-dev.tar.gz
|
||||
|
||||
# 进入解压后的目录
|
||||
cd unilab-pack-osx-arm64-dev
|
||||
|
||||
# 运行安装脚本
|
||||
bash install_unilab.sh
|
||||
```
|
||||
|
||||
**Linux**:
|
||||
|
||||
```bash
|
||||
# 解压下载的压缩包
|
||||
tar -xzf unilab-pack-linux-64-dev.tar.gz
|
||||
|
||||
# 进入解压后的目录
|
||||
cd unilab-pack-linux-64-dev
|
||||
|
||||
# 添加执行权限(如果需要)
|
||||
chmod +x install_unilab.sh
|
||||
|
||||
# 运行安装脚本
|
||||
./install_unilab.sh
|
||||
```
|
||||
|
||||
#### 第三步:激活环境
|
||||
|
||||
```bash
|
||||
conda activate unilab
|
||||
```
|
||||
|
||||
激活后,您的命令行提示符应该会显示 `(unilab)` 前缀。
|
||||
|
||||
---
|
||||
|
||||
## 方式二:手动安装(标准用户)
|
||||
|
||||
适合生产环境和需要灵活配置的用户。
|
||||
|
||||
### 第一步:安装 Mamba 环境管理器
|
||||
|
||||
Mamba 是 Conda 的快速替代品,我们强烈推荐使用 Mamba 来管理 Uni-Lab 环境。
|
||||
|
||||
#### Windows
|
||||
|
||||
下载并安装 Miniforge(包含 Mamba):
|
||||
|
||||
```powershell
|
||||
# 访问 https://github.com/conda-forge/miniforge/releases
|
||||
# 下载 Miniforge3-Windows-x86_64.exe
|
||||
# 运行安装程序
|
||||
|
||||
# 也可以使用镜像站 https://mirrors.tuna.tsinghua.edu.cn/github-release/conda-forge/miniforge/LatestRelease/
|
||||
# 下载 Miniforge3-Windows-x86_64.exe
|
||||
# 运行安装程序
|
||||
```
|
||||
|
||||
#### Linux/macOS
|
||||
|
||||
```bash
|
||||
# 下载 Miniforge 安装脚本
|
||||
curl -L -O "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"
|
||||
|
||||
# 运行安装
|
||||
bash Miniforge3-$(uname)-$(uname -m).sh
|
||||
|
||||
# 按照提示完成安装,建议选择 yes 来初始化
|
||||
```
|
||||
|
||||
安装完成后,重新打开终端使 Mamba 生效。
|
||||
|
||||
### 第二步:创建 Uni-Lab 环境
|
||||
|
||||
使用以下命令创建 Uni-Lab 专用环境:
|
||||
|
||||
```bash
|
||||
mamba create -n unilab python=3.11.11 # 目前ros2组件依赖版本大多为3.11.11
|
||||
mamba activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
其中 `YOUR_OS` 是您的操作系统,可选值 `win64`, `linux-64`, `osx-64`, `osx-arm64`
|
||||
**参数说明**:
|
||||
|
||||
若需将依赖安装进当前环境,请执行
|
||||
- `-n unilab`: 创建名为 "unilab" 的环境
|
||||
- `uni-lab::unilabos`: 从 uni-lab channel 安装 unilabos 包
|
||||
- `-c robostack-staging -c conda-forge`: 添加额外的软件源
|
||||
|
||||
```shell
|
||||
conda env update --file unilabos-<YOUR_OS>.yml
|
||||
**如果遇到网络问题**,可以使用清华镜像源加速下载:
|
||||
|
||||
```bash
|
||||
# 配置清华镜像源
|
||||
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/
|
||||
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/
|
||||
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
|
||||
|
||||
# 然后重新执行安装命令
|
||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging
|
||||
```
|
||||
|
||||
随后,可在本仓库安装 `unilabos` 的开发版:
|
||||
### 第三步:激活环境
|
||||
|
||||
```shell
|
||||
pip install .
|
||||
```bash
|
||||
conda activate unilab
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式三:开发者安装
|
||||
|
||||
适用于需要修改 Uni-Lab 源代码或开发新设备驱动的开发者。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 已安装 Git
|
||||
- 已安装 Mamba/Conda
|
||||
- 有 GitHub 账号(如需自定义 msgs)
|
||||
- 基本的 Python 开发知识
|
||||
|
||||
### 第一步:克隆仓库
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
```
|
||||
|
||||
如果您需要贡献代码,建议先 Fork 仓库:
|
||||
|
||||
1. 访问 https://github.com/dptech-corp/Uni-Lab-OS
|
||||
2. 点击右上角的 "Fork" 按钮
|
||||
3. Clone 您的 Fork 版本:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/Uni-Lab-OS.git
|
||||
cd Uni-Lab-OS
|
||||
```
|
||||
|
||||
### 第二步:安装基础环境
|
||||
|
||||
**推荐方式**:先通过**方式一(一键安装)**或**方式二(手动安装)**完成基础环境的安装,这将包含所有必需的依赖项(ROS2、msgs 等)。
|
||||
|
||||
#### 选项 A:通过一键安装(推荐)
|
||||
|
||||
参考上文"方式一:一键安装",完成基础环境的安装后,激活环境:
|
||||
|
||||
```bash
|
||||
conda activate unilab
|
||||
```
|
||||
|
||||
#### 选项 B:通过手动安装
|
||||
|
||||
参考上文"方式二:手动安装",创建并安装环境:
|
||||
|
||||
```bash
|
||||
mamba create -n unilab python=3.11.11
|
||||
conda activate unilab
|
||||
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
**说明**:这会安装包括 Python 3.11.11、ROS2 Humble、ros-humble-unilabos-msgs 和所有必需依赖
|
||||
|
||||
### 第三步:切换到开发版本
|
||||
|
||||
现在你已经有了一个完整可用的 Uni-Lab 环境,接下来将 unilabos 包切换为开发版本:
|
||||
|
||||
```bash
|
||||
# 确保环境已激活
|
||||
conda activate unilab
|
||||
|
||||
# 卸载 pip 安装的 unilabos(保留所有 conda 依赖)
|
||||
pip uninstall unilabos -y
|
||||
|
||||
# 克隆 dev 分支(如果还未克隆)
|
||||
cd /path/to/your/workspace
|
||||
git clone -b dev https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||
# 或者如果已经克隆,切换到 dev 分支
|
||||
cd Uni-Lab-OS
|
||||
git checkout dev
|
||||
git pull
|
||||
|
||||
# 以可编辑模式安装开发版 unilabos
|
||||
pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
|
||||
- `-e`: editable mode(可编辑模式),代码修改立即生效,无需重新安装
|
||||
- `-i`: 使用清华镜像源加速下载
|
||||
- `pip uninstall unilabos`: 只卸载 pip 安装的 unilabos 包,不影响 conda 安装的其他依赖(如 ROS2、msgs 等)
|
||||
|
||||
### 第四步:安装或自定义 ros-humble-unilabos-msgs(可选)
|
||||
|
||||
Uni-Lab 使用 ROS2 消息系统进行设备间通信。如果你使用方式一或方式二安装,msgs 包已经自动安装。
|
||||
|
||||
#### 使用已安装的 msgs(大多数用户)
|
||||
|
||||
如果你不需要修改 msgs,可以跳过此步骤,直接使用已安装的 msgs 包。验证安装:
|
||||
|
||||
```bash
|
||||
# 列出所有 unilabos_msgs 接口
|
||||
ros2 interface list | grep unilabos_msgs
|
||||
|
||||
# 查看特定 action 定义
|
||||
ros2 interface show unilabos_msgs/action/DeviceCmd
|
||||
```
|
||||
|
||||
#### 自定义 msgs(高级用户)
|
||||
|
||||
如果你需要:
|
||||
|
||||
- 添加新的 ROS2 action 定义
|
||||
- 修改现有 msg/srv/action 接口
|
||||
- 为特定设备定制通信协议
|
||||
|
||||
请参考 **[添加新动作指令(Action)指南](../developer_guide/add_action.md)**,该指南详细介绍了如何:
|
||||
|
||||
- 编写新的 Action 定义
|
||||
- 在线构建 Action(通过 GitHub Actions)
|
||||
- 下载并安装自定义的 msgs 包
|
||||
- 测试和验证新的 Action
|
||||
|
||||
```bash
|
||||
# 安装自定义构建的 msgs 包
|
||||
mamba remove --force ros-humble-unilabos-msgs
|
||||
mamba config set safety_checks disabled # 关闭 md5 检查
|
||||
mamba install /path/to/ros-humble-unilabos-msgs-*.conda --offline
|
||||
```
|
||||
|
||||
### 第五步:验证开发环境
|
||||
|
||||
完成上述步骤后,验证开发环境是否正确配置:
|
||||
|
||||
```bash
|
||||
# 确保环境已激活
|
||||
conda activate unilab
|
||||
|
||||
# 检查 ROS2 环境
|
||||
ros2 --version
|
||||
|
||||
# 检查 msgs 包
|
||||
ros2 interface list | grep unilabos_msgs
|
||||
|
||||
# 检查 Python 可以导入 unilabos
|
||||
python -c "import unilabos; print(f'Uni-Lab版本: {unilabos.__version__}')"
|
||||
|
||||
# 检查 unilab 命令
|
||||
unilab --help
|
||||
```
|
||||
|
||||
如果所有命令都正常输出,说明开发环境配置成功!
|
||||
|
||||
### 开发工具推荐
|
||||
|
||||
#### IDE
|
||||
|
||||
- **PyCharm Professional**: 强大的 Python IDE,支持远程调试
|
||||
- **VS Code**: 轻量级,配合 Python 扩展使用
|
||||
- **Vim/Emacs**: 适合终端开发
|
||||
|
||||
#### 推荐的 VS Code 扩展
|
||||
|
||||
- Python
|
||||
- Pylance
|
||||
- ROS
|
||||
- URDF
|
||||
- YAML
|
||||
|
||||
#### 调试工具
|
||||
|
||||
```bash
|
||||
# 安装调试工具
|
||||
pip install ipdb pytest pytest-cov -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
|
||||
# 代码质量检查
|
||||
pip install black flake8 mypy -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
```
|
||||
|
||||
### 设置 pre-commit 钩子(可选)
|
||||
|
||||
```bash
|
||||
# 安装 pre-commit
|
||||
pip install pre-commit -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
|
||||
# 设置钩子
|
||||
pre-commit install
|
||||
|
||||
# 手动运行检查
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证安装
|
||||
|
||||
无论使用哪种安装方式,都应该验证安装是否成功。
|
||||
|
||||
### 基本验证
|
||||
|
||||
```bash
|
||||
# 确保已激活环境
|
||||
conda activate unilab # 或 unilab-dev
|
||||
|
||||
# 检查 unilab 命令
|
||||
unilab --help
|
||||
```
|
||||
|
||||
您应该看到类似以下的输出:
|
||||
|
||||
```
|
||||
usage: unilab [-h] [-g GRAPH] [-c CONTROLLERS] [--registry_path REGISTRY_PATH]
|
||||
[--working_dir WORKING_DIR] [--backend {ros,simple,automancer}]
|
||||
...
|
||||
```
|
||||
|
||||
### 检查版本
|
||||
|
||||
```bash
|
||||
python -c "import unilabos; print(f'Uni-Lab版本: {unilabos.__version__}')"
|
||||
```
|
||||
|
||||
### 使用验证脚本(方式一)
|
||||
|
||||
如果使用一键安装,可以运行预打包的验证脚本:
|
||||
|
||||
```bash
|
||||
# 确保已激活环境
|
||||
conda activate unilab
|
||||
|
||||
# 运行验证脚本
|
||||
python verify_installation.py
|
||||
```
|
||||
|
||||
如果看到 "✓ All checks passed!",说明安装成功!
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1: 找不到 unilab 命令
|
||||
|
||||
**原因**: 环境未正确激活或 PATH 未设置
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 确保激活了正确的环境
|
||||
conda activate unilab
|
||||
|
||||
# 检查 unilab 是否在 PATH 中
|
||||
which unilab # Linux/macOS
|
||||
where unilab # Windows
|
||||
```
|
||||
|
||||
### 问题 2: 包冲突或依赖错误
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 删除旧环境重新创建
|
||||
conda deactivate
|
||||
conda env remove -n unilab
|
||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
### 问题 3: 下载速度慢
|
||||
|
||||
**解决方案**: 使用国内镜像源(清华、中科大等)
|
||||
|
||||
```bash
|
||||
# 查看当前 channel 配置
|
||||
conda config --show channels
|
||||
|
||||
# 添加清华镜像
|
||||
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
|
||||
```
|
||||
|
||||
### 问题 4: 权限错误
|
||||
|
||||
**Windows 解决方案**: 以管理员身份运行命令提示符
|
||||
|
||||
**Linux/macOS 解决方案**:
|
||||
|
||||
```bash
|
||||
# 不要使用 sudo 安装 conda 包
|
||||
# 如果 conda 安装在需要权限的位置,考虑重新安装 conda 到用户目录
|
||||
```
|
||||
|
||||
### 问题 5: 安装脚本找不到 conda(方式一)
|
||||
|
||||
**解决方案**: 确保你已经安装了 conda/miniconda/miniforge,并且安装在标准位置:
|
||||
|
||||
- **Windows**:
|
||||
|
||||
- `%USERPROFILE%\miniforge3`
|
||||
- `%USERPROFILE%\miniconda3`
|
||||
- `%USERPROFILE%\anaconda3`
|
||||
- `C:\ProgramData\miniforge3`
|
||||
|
||||
- **macOS/Linux**:
|
||||
- `~/miniforge3`
|
||||
- `~/miniconda3`
|
||||
- `~/anaconda3`
|
||||
- `/opt/conda`
|
||||
|
||||
如果安装在其他位置,可以先激活 conda base 环境,然后手动运行安装脚本。
|
||||
|
||||
### 问题 6: 安装后激活环境提示找不到?
|
||||
|
||||
**解决方案**: 尝试以下方法:
|
||||
|
||||
```bash
|
||||
# 方法 1: 使用 conda activate
|
||||
conda activate unilab
|
||||
|
||||
# 方法 2: 使用完整路径激活(Windows)
|
||||
call C:\Users\{YourUsername}\miniforge3\envs\unilab\Scripts\activate.bat
|
||||
|
||||
# 方法 2: 使用完整路径激活(Unix)
|
||||
source ~/miniforge3/envs/unilab/bin/activate
|
||||
```
|
||||
|
||||
### 问题 7: conda-unpack 失败怎么办?(方式一)
|
||||
|
||||
**解决方案**: 尝试手动运行:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
cd %CONDA_PREFIX%\envs\unilab
|
||||
.\Scripts\conda-unpack.exe
|
||||
|
||||
# macOS/Linux
|
||||
cd $CONDA_PREFIX/envs/unilab
|
||||
./bin/conda-unpack
|
||||
```
|
||||
|
||||
### 问题 8: 环境很大,有办法减小吗?
|
||||
|
||||
**解决方案**: 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。如果空间有限,考虑使用方式二手动安装,只安装需要的组件。
|
||||
|
||||
### 问题 9: 如何更新到最新版本?
|
||||
|
||||
**解决方案**:
|
||||
|
||||
**方式一用户**: 重新下载最新的预打包环境,运行安装脚本时选择覆盖现有环境。
|
||||
|
||||
**方式二/三用户**: 在现有环境中更新:
|
||||
|
||||
```bash
|
||||
conda activate unilab
|
||||
|
||||
# 更新 unilabos
|
||||
cd /path/to/Uni-Lab-OS
|
||||
git pull
|
||||
pip install -e . --upgrade -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
|
||||
# 更新 ros-humble-unilabos-msgs
|
||||
mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 下一步
|
||||
|
||||
安装完成后,请继续:
|
||||
|
||||
- **快速启动**: 学习如何首次启动 Uni-Lab
|
||||
- **配置指南**: 配置您的实验室环境和设备
|
||||
- **运行示例**: 查看启动示例和最佳实践
|
||||
- **开发指南**:
|
||||
- 添加新设备驱动
|
||||
- 添加新物料资源
|
||||
- 了解工作站架构
|
||||
|
||||
## 需要帮助?
|
||||
|
||||
- **故障排查**: 查看更详细的故障排查信息
|
||||
- **GitHub Issues**: [报告问题](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||
- **开发者文档**: 查看开发者指南获取更多技术细节
|
||||
- **社区讨论**: [GitHub Discussions](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
|
||||
|
||||
---
|
||||
|
||||
**提示**:
|
||||
|
||||
- 生产环境推荐使用方式二(手动安装)的稳定版本
|
||||
- 开发和测试推荐使用方式三(开发者安装)
|
||||
- 快速体验和演示推荐使用方式一(一键安装)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Uni-Lab 启动
|
||||
# Uni-Lab 启动指南
|
||||
|
||||
安装完毕后,可以通过 `unilab` 命令行启动:
|
||||
|
||||
@@ -8,70 +8,239 @@ 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}`,只输入<your-registry-path>即可,支持多次--registry_path指定多个目录。
|
||||
|
||||
## 通信中间件 `--backend`
|
||||
|
||||
目前 Uni-Lab 仅支持 ros2 作为通信中间件。
|
||||
目前 Uni-Lab 支持以下通信中间件:
|
||||
|
||||
- **ros** (默认):基于 ROS2 的通信
|
||||
- **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 界面进行可视化 (基于Pylabrobot)
|
||||
- **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. 设备图谱问题
|
||||
|
||||
如果设备加载失败:
|
||||
|
||||
- 检查图谱文件格式是否正确
|
||||
- 验证设备连接和端点配置
|
||||
- 确保注册表路径正确
|
||||
|
||||
22
package.xml
@@ -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>
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.4
|
||||
version: 0.10.11
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
|
||||
41
recipes/ros-humble-unilabos-msgs/bld_ament_cmake.bat
Normal file
@@ -0,0 +1,41 @@
|
||||
:: Generated by vinca http://github.com/RoboStack/vinca.
|
||||
:: DO NOT EDIT!
|
||||
setlocal EnableDelayedExpansion
|
||||
|
||||
set "PYTHONPATH=%LIBRARY_PREFIX%\lib\site-packages;%SP_DIR%"
|
||||
|
||||
:: MSVC is preferred.
|
||||
set CC=cl.exe
|
||||
set CXX=cl.exe
|
||||
|
||||
rd /s /q build
|
||||
mkdir build
|
||||
pushd build
|
||||
|
||||
:: set "CMAKE_GENERATOR=Ninja"
|
||||
|
||||
:: try to fix long paths issues by using default generator
|
||||
set "CMAKE_GENERATOR=Visual Studio %VS_MAJOR% %VS_YEAR%"
|
||||
set "SP_DIR_FORWARDSLASHES=%SP_DIR:\=/%"
|
||||
|
||||
set PYTHON="%PREFIX%\python.exe"
|
||||
|
||||
cmake ^
|
||||
-G "%CMAKE_GENERATOR%" ^
|
||||
-DCMAKE_INSTALL_PREFIX=%LIBRARY_PREFIX% ^
|
||||
-DCMAKE_BUILD_TYPE=Release ^
|
||||
-DCMAKE_INSTALL_SYSTEM_RUNTIME_LIBS_SKIP=True ^
|
||||
-DPYTHON_EXECUTABLE=%PYTHON% ^
|
||||
-DPython_EXECUTABLE=%PYTHON% ^
|
||||
-DPython3_EXECUTABLE=%PYTHON% ^
|
||||
-DSETUPTOOLS_DEB_LAYOUT=OFF ^
|
||||
-DBUILD_SHARED_LIBS=ON ^
|
||||
-DBUILD_TESTING=OFF ^
|
||||
-DCMAKE_OBJECT_PATH_MAX=255 ^
|
||||
-DPYTHON_INSTALL_DIR=%SP_DIR_FORWARDSLASHES% ^
|
||||
--compile-no-warning-as-error ^
|
||||
%SRC_DIR%\%PKG_NAME%\src\work
|
||||
if errorlevel 1 exit 1
|
||||
|
||||
cmake --build . --config Release --target install
|
||||
if errorlevel 1 exit 1
|
||||
71
recipes/ros-humble-unilabos-msgs/build_ament_cmake.sh
Normal file
@@ -0,0 +1,71 @@
|
||||
# Generated by vinca http://github.com/RoboStack/vinca.
|
||||
# DO NOT EDIT!
|
||||
|
||||
rm -rf build
|
||||
mkdir build
|
||||
cd build
|
||||
|
||||
# necessary for correctly linking SIP files (from python_qt_bindings)
|
||||
export LINK=$CXX
|
||||
|
||||
if [[ "$CONDA_BUILD_CROSS_COMPILATION" != "1" ]]; then
|
||||
PYTHON_EXECUTABLE=$PREFIX/bin/python
|
||||
PKG_CONFIG_EXECUTABLE=$PREFIX/bin/pkg-config
|
||||
OSX_DEPLOYMENT_TARGET="10.15"
|
||||
else
|
||||
PYTHON_EXECUTABLE=$BUILD_PREFIX/bin/python
|
||||
PKG_CONFIG_EXECUTABLE=$BUILD_PREFIX/bin/pkg-config
|
||||
OSX_DEPLOYMENT_TARGET="11.0"
|
||||
fi
|
||||
|
||||
echo "USING PYTHON_EXECUTABLE=${PYTHON_EXECUTABLE}"
|
||||
echo "USING PKG_CONFIG_EXECUTABLE=${PKG_CONFIG_EXECUTABLE}"
|
||||
|
||||
export ROS_PYTHON_VERSION=`$PYTHON_EXECUTABLE -c "import sys; print('%i.%i' % (sys.version_info[0:2]))"`
|
||||
echo "Using Python ${ROS_PYTHON_VERSION}"
|
||||
# Fix up SP_DIR which for some reason might contain a path to a wrong Python version
|
||||
FIXED_SP_DIR=$(echo $SP_DIR | sed -E "s/python[0-9]+\.[0-9]+/python$ROS_PYTHON_VERSION/")
|
||||
echo "Using site-package dir ${FIXED_SP_DIR}"
|
||||
|
||||
# see https://github.com/conda-forge/cross-python-feedstock/issues/24
|
||||
if [[ "$CONDA_BUILD_CROSS_COMPILATION" == "1" ]]; then
|
||||
find $PREFIX/lib/cmake -type f -exec sed -i "s~\${_IMPORT_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~${BUILD_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~g" {} + || true
|
||||
find $PREFIX/share/rosidl* -type f -exec sed -i "s~$PREFIX/lib/python${ROS_PYTHON_VERSION}/site-packages~${BUILD_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~g" {} + || true
|
||||
find $PREFIX/share/rosidl* -type f -exec sed -i "s~\${_IMPORT_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~${BUILD_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~g" {} + || true
|
||||
find $PREFIX/lib/cmake -type f -exec sed -i "s~message(FATAL_ERROR \"The imported target~message(WARNING \"The imported target~g" {} + || true
|
||||
fi
|
||||
|
||||
if [[ $target_platform =~ linux.* ]]; then
|
||||
export CFLAGS="${CFLAGS} -D__STDC_FORMAT_MACROS=1"
|
||||
export CXXFLAGS="${CXXFLAGS} -D__STDC_FORMAT_MACROS=1"
|
||||
fi;
|
||||
|
||||
# Needed for qt-gui-cpp ..
|
||||
if [[ $target_platform =~ linux.* ]]; then
|
||||
ln -s $GCC ${BUILD_PREFIX}/bin/gcc
|
||||
ln -s $GXX ${BUILD_PREFIX}/bin/g++
|
||||
fi;
|
||||
|
||||
cmake \
|
||||
-G "Ninja" \
|
||||
-DCMAKE_INSTALL_PREFIX=$PREFIX \
|
||||
-DCMAKE_PREFIX_PATH=$PREFIX \
|
||||
-DAMENT_PREFIX_PATH=$PREFIX \
|
||||
-DCMAKE_INSTALL_LIBDIR=lib \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DPYTHON_EXECUTABLE=$PYTHON_EXECUTABLE \
|
||||
-DPython_EXECUTABLE=$PYTHON_EXECUTABLE \
|
||||
-DPython3_EXECUTABLE=$PYTHON_EXECUTABLE \
|
||||
-DPython3_FIND_STRATEGY=LOCATION \
|
||||
-DPKG_CONFIG_EXECUTABLE=$PKG_CONFIG_EXECUTABLE \
|
||||
-DPYTHON_INSTALL_DIR=$FIXED_SP_DIR \
|
||||
-DSETUPTOOLS_DEB_LAYOUT=OFF \
|
||||
-DCATKIN_SKIP_TESTING=$SKIP_TESTING \
|
||||
-DCMAKE_INSTALL_SYSTEM_RUNTIME_LIBS_SKIP=True \
|
||||
-DBUILD_SHARED_LIBS=ON \
|
||||
-DBUILD_TESTING=OFF \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=$OSX_DEPLOYMENT_TARGET \
|
||||
--compile-no-warning-as-error \
|
||||
$SRC_DIR/$PKG_NAME/src/work
|
||||
|
||||
cmake --build . --config Release --target install
|
||||
61
recipes/ros-humble-unilabos-msgs/recipe.yaml
Normal file
@@ -0,0 +1,61 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.9.7
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
folder: ros-humble-unilabos-msgs/src/work
|
||||
|
||||
build:
|
||||
script:
|
||||
sel(win): bld_ament_cmake.bat
|
||||
sel(unix): build_ament_cmake.sh
|
||||
number: 5
|
||||
about:
|
||||
home: https://www.ros.org/
|
||||
license: BSD-3-Clause
|
||||
summary: |
|
||||
Robot Operating System
|
||||
|
||||
extra:
|
||||
recipe-maintainers:
|
||||
- ros-forge
|
||||
|
||||
requirements:
|
||||
build:
|
||||
- "{{ compiler('cxx') }}"
|
||||
- "{{ compiler('c') }}"
|
||||
- sel(linux64): sysroot_linux-64 2.17
|
||||
- ninja
|
||||
- setuptools
|
||||
- sel(unix): make
|
||||
- sel(unix): coreutils
|
||||
- sel(osx): tapi
|
||||
- sel(build_platform != target_platform): pkg-config
|
||||
- cmake
|
||||
- cython
|
||||
- sel(win): vs2022_win-64
|
||||
- sel(build_platform != target_platform): python
|
||||
- sel(build_platform != target_platform): cross-python_{{ target_platform }}
|
||||
- sel(build_platform != target_platform): numpy
|
||||
host:
|
||||
- numpy
|
||||
- pip
|
||||
- sel(build_platform == target_platform): pkg-config
|
||||
- robostack-staging::ros-humble-action-msgs
|
||||
- robostack-staging::ros-humble-ament-cmake
|
||||
- robostack-staging::ros-humble-ament-lint-auto
|
||||
- robostack-staging::ros-humble-ament-lint-common
|
||||
- robostack-staging::ros-humble-ros-environment
|
||||
- robostack-staging::ros-humble-ros-workspace
|
||||
- robostack-staging::ros-humble-rosidl-default-generators
|
||||
- robostack-staging::ros-humble-std-msgs
|
||||
- robostack-staging::ros-humble-geometry-msgs
|
||||
- robostack-staging::ros2-distro-mutex=0.5.*
|
||||
run:
|
||||
- robostack-staging::ros-humble-action-msgs
|
||||
- robostack-staging::ros-humble-ros-workspace
|
||||
- robostack-staging::ros-humble-rosidl-default-runtime
|
||||
- robostack-staging::ros-humble-std-msgs
|
||||
- robostack-staging::ros-humble-geometry-msgs
|
||||
# - robostack-staging::ros2-distro-mutex=0.6.*
|
||||
- sel(osx and x86_64): __osx >={{ MACOSX_DEPLOYMENT_TARGET|default('10.14') }}
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.4"
|
||||
version: "0.10.11"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
190
scripts/create_readme.py
Normal 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())
|
||||
148
scripts/create_zip_archive.py
Normal 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()
|
||||
@@ -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!
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
UniLabOS Installation Verification Script
|
||||
=========================================
|
||||
@@ -7,7 +8,10 @@ This script verifies that UniLabOS and its dependencies are correctly installed.
|
||||
Run this script after installing the conda-pack environment to ensure everything works.
|
||||
|
||||
Usage:
|
||||
python verify_installation.py
|
||||
python verify_installation.py [--auto-install]
|
||||
|
||||
Options:
|
||||
--auto-install Automatically install missing packages
|
||||
|
||||
Or in the conda environment:
|
||||
conda activate unilab
|
||||
@@ -15,10 +19,41 @@ Usage:
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
|
||||
# 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") # type: ignore
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore
|
||||
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:
|
||||
|
||||
def check_package(package_name: str, display_name: str | None = None) -> bool:
|
||||
"""
|
||||
Check if a package can be imported.
|
||||
|
||||
@@ -34,10 +69,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,18 +82,34 @@ 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
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all verification checks."""
|
||||
# Parse command line arguments
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Verify UniLabOS installation",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--auto-install",
|
||||
action="store_true",
|
||||
help="Automatically install missing packages",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("UniLabOS Installation Verification")
|
||||
print("=" * 60)
|
||||
if args.auto_install:
|
||||
print("Mode: Auto-install missing packages")
|
||||
else:
|
||||
print("Mode: Verification only")
|
||||
print()
|
||||
|
||||
all_passed = True
|
||||
@@ -78,26 +129,25 @@ 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 with optional auto-install
|
||||
# Set show_details=False to suppress detailed Chinese output that may cause encoding issues
|
||||
env_check_passed = check_environment(auto_install=args.auto_install, 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")
|
||||
if not args.auto_install:
|
||||
print(" Hint: Run with --auto-install to automatically install missing packages")
|
||||
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 +156,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
|
||||
|
||||
|
||||
|
||||
695
scripts/workflow.py
Normal file
@@ -0,0 +1,695 @@
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import networkx as nx
|
||||
import matplotlib.pyplot as plt
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SimpleGraph:
|
||||
"""简单的有向图实现,用于构建工作流图"""
|
||||
|
||||
def __init__(self):
|
||||
self.nodes = {}
|
||||
self.edges = []
|
||||
|
||||
def add_node(self, node_id, **attrs):
|
||||
"""添加节点"""
|
||||
self.nodes[node_id] = attrs
|
||||
|
||||
def add_edge(self, source, target, **attrs):
|
||||
"""添加边"""
|
||||
edge = {"source": source, "target": target, **attrs}
|
||||
self.edges.append(edge)
|
||||
|
||||
def to_dict(self):
|
||||
"""转换为工作流图格式"""
|
||||
nodes_list = []
|
||||
for node_id, attrs in self.nodes.items():
|
||||
node_attrs = attrs.copy()
|
||||
params = node_attrs.pop("parameters", {}) or {}
|
||||
node_attrs.update(params)
|
||||
nodes_list.append({"id": node_id, **node_attrs})
|
||||
|
||||
return {
|
||||
"directed": True,
|
||||
"multigraph": False,
|
||||
"graph": {},
|
||||
"nodes": nodes_list,
|
||||
"links": self.edges,
|
||||
}
|
||||
|
||||
|
||||
def extract_json_from_markdown(text: str) -> str:
|
||||
"""从markdown代码块中提取JSON"""
|
||||
text = text.strip()
|
||||
if text.startswith("```json\n"):
|
||||
text = text[8:]
|
||||
if text.startswith("```\n"):
|
||||
text = text[4:]
|
||||
if text.endswith("\n```"):
|
||||
text = text[:-4]
|
||||
return text
|
||||
|
||||
|
||||
def convert_to_type(val: str) -> Any:
|
||||
"""将字符串值转换为适当的数据类型"""
|
||||
if val == "True":
|
||||
return True
|
||||
if val == "False":
|
||||
return False
|
||||
if val == "?":
|
||||
return None
|
||||
if val.endswith(" g"):
|
||||
return float(val.split(" ")[0])
|
||||
if val.endswith("mg"):
|
||||
return float(val.split("mg")[0])
|
||||
elif val.endswith("mmol"):
|
||||
return float(val.split("mmol")[0]) / 1000
|
||||
elif val.endswith("mol"):
|
||||
return float(val.split("mol")[0])
|
||||
elif val.endswith("ml"):
|
||||
return float(val.split("ml")[0])
|
||||
elif val.endswith("RPM"):
|
||||
return float(val.split("RPM")[0])
|
||||
elif val.endswith(" °C"):
|
||||
return float(val.split(" ")[0])
|
||||
elif val.endswith(" %"):
|
||||
return float(val.split(" ")[0])
|
||||
return val
|
||||
|
||||
|
||||
def refactor_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""统一的数据重构函数,根据操作类型自动选择模板"""
|
||||
refactored_data = []
|
||||
|
||||
# 定义操作映射,包含生物实验和有机化学的所有操作
|
||||
OPERATION_MAPPING = {
|
||||
# 生物实验操作
|
||||
"transfer_liquid": "SynBioFactory-liquid_handler.prcxi-transfer_liquid",
|
||||
"transfer": "SynBioFactory-liquid_handler.biomek-transfer",
|
||||
"incubation": "SynBioFactory-liquid_handler.biomek-incubation",
|
||||
"move_labware": "SynBioFactory-liquid_handler.biomek-move_labware",
|
||||
"oscillation": "SynBioFactory-liquid_handler.biomek-oscillation",
|
||||
# 有机化学操作
|
||||
"HeatChillToTemp": "SynBioFactory-workstation-HeatChillProtocol",
|
||||
"StopHeatChill": "SynBioFactory-workstation-HeatChillStopProtocol",
|
||||
"StartHeatChill": "SynBioFactory-workstation-HeatChillStartProtocol",
|
||||
"HeatChill": "SynBioFactory-workstation-HeatChillProtocol",
|
||||
"Dissolve": "SynBioFactory-workstation-DissolveProtocol",
|
||||
"Transfer": "SynBioFactory-workstation-TransferProtocol",
|
||||
"Evaporate": "SynBioFactory-workstation-EvaporateProtocol",
|
||||
"Recrystallize": "SynBioFactory-workstation-RecrystallizeProtocol",
|
||||
"Filter": "SynBioFactory-workstation-FilterProtocol",
|
||||
"Dry": "SynBioFactory-workstation-DryProtocol",
|
||||
"Add": "SynBioFactory-workstation-AddProtocol",
|
||||
}
|
||||
|
||||
UNSUPPORTED_OPERATIONS = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||
|
||||
for step in data:
|
||||
operation = step.get("action")
|
||||
if not operation or operation in UNSUPPORTED_OPERATIONS:
|
||||
continue
|
||||
|
||||
# 处理重复操作
|
||||
if operation == "Repeat":
|
||||
times = step.get("times", step.get("parameters", {}).get("times", 1))
|
||||
sub_steps = step.get("steps", step.get("parameters", {}).get("steps", []))
|
||||
for i in range(int(times)):
|
||||
sub_data = refactor_data(sub_steps)
|
||||
refactored_data.extend(sub_data)
|
||||
continue
|
||||
|
||||
# 获取模板名称
|
||||
template = OPERATION_MAPPING.get(operation)
|
||||
if not template:
|
||||
# 自动推断模板类型
|
||||
if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]:
|
||||
template = f"SynBioFactory-liquid_handler.biomek-{operation}"
|
||||
else:
|
||||
template = f"SynBioFactory-workstation-{operation}Protocol"
|
||||
|
||||
# 创建步骤数据
|
||||
step_data = {
|
||||
"template": template,
|
||||
"description": step.get("description", step.get("purpose", f"{operation} operation")),
|
||||
"lab_node_type": "Device",
|
||||
"parameters": step.get("parameters", step.get("action_args", {})),
|
||||
}
|
||||
refactored_data.append(step_data)
|
||||
|
||||
return refactored_data
|
||||
|
||||
|
||||
def build_protocol_graph(
|
||||
labware_info: List[Dict[str, Any]], protocol_steps: List[Dict[str, Any]], workstation_name: str
|
||||
) -> SimpleGraph:
|
||||
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑"""
|
||||
G = SimpleGraph()
|
||||
resource_last_writer = {}
|
||||
LAB_NAME = "SynBioFactory"
|
||||
|
||||
protocol_steps = refactor_data(protocol_steps)
|
||||
|
||||
# 检查协议步骤中的模板来判断协议类型
|
||||
has_biomek_template = any(
|
||||
("biomek" in step.get("template", "")) or ("prcxi" in step.get("template", ""))
|
||||
for step in protocol_steps
|
||||
)
|
||||
|
||||
if has_biomek_template:
|
||||
# 生物实验协议图构建
|
||||
for labware_id, labware in labware_info.items():
|
||||
node_id = str(uuid.uuid4())
|
||||
|
||||
labware_attrs = labware.copy()
|
||||
labware_id = labware_attrs.pop("id", labware_attrs.get("name", f"labware_{uuid.uuid4()}"))
|
||||
labware_attrs["description"] = labware_id
|
||||
labware_attrs["lab_node_type"] = (
|
||||
"Reagent" if "Plate" in str(labware_id) else "Labware" if "Rack" in str(labware_id) else "Sample"
|
||||
)
|
||||
labware_attrs["device_id"] = workstation_name
|
||||
|
||||
G.add_node(node_id, template=f"{LAB_NAME}-host_node-create_resource", **labware_attrs)
|
||||
resource_last_writer[labware_id] = f"{node_id}:labware"
|
||||
|
||||
# 处理协议步骤
|
||||
prev_node = None
|
||||
for i, step in enumerate(protocol_steps):
|
||||
node_id = str(uuid.uuid4())
|
||||
G.add_node(node_id, **step)
|
||||
|
||||
# 添加控制流边
|
||||
if prev_node is not None:
|
||||
G.add_edge(prev_node, node_id, source_port="ready", target_port="ready")
|
||||
prev_node = node_id
|
||||
|
||||
# 处理物料流
|
||||
params = step.get("parameters", {})
|
||||
if "sources" in params and params["sources"] in resource_last_writer:
|
||||
source_node, source_port = resource_last_writer[params["sources"]].split(":")
|
||||
G.add_edge(source_node, node_id, source_port=source_port, target_port="labware")
|
||||
|
||||
if "targets" in params:
|
||||
resource_last_writer[params["targets"]] = f"{node_id}:labware"
|
||||
|
||||
# 添加协议结束节点
|
||||
end_id = str(uuid.uuid4())
|
||||
G.add_node(end_id, template=f"{LAB_NAME}-liquid_handler.biomek-run_protocol")
|
||||
if prev_node is not None:
|
||||
G.add_edge(prev_node, end_id, source_port="ready", target_port="ready")
|
||||
|
||||
else:
|
||||
# 有机化学协议图构建
|
||||
WORKSTATION_ID = workstation_name
|
||||
|
||||
# 为所有labware创建资源节点
|
||||
for item_id, item in labware_info.items():
|
||||
# item_id = item.get("id") or item.get("name", f"item_{uuid.uuid4()}")
|
||||
node_id = str(uuid.uuid4())
|
||||
|
||||
# 判断节点类型
|
||||
if item.get("type") == "hardware" or "reactor" in str(item_id).lower():
|
||||
if "reactor" not in str(item_id).lower():
|
||||
continue
|
||||
lab_node_type = "Sample"
|
||||
description = f"Prepare Reactor: {item_id}"
|
||||
liquid_type = []
|
||||
liquid_volume = []
|
||||
else:
|
||||
lab_node_type = "Reagent"
|
||||
description = f"Add Reagent to Flask: {item_id}"
|
||||
liquid_type = [item_id]
|
||||
liquid_volume = [1e5]
|
||||
|
||||
G.add_node(
|
||||
node_id,
|
||||
template=f"{LAB_NAME}-host_node-create_resource",
|
||||
description=description,
|
||||
lab_node_type=lab_node_type,
|
||||
res_id=item_id,
|
||||
device_id=WORKSTATION_ID,
|
||||
class_name="container",
|
||||
parent=WORKSTATION_ID,
|
||||
bind_locations={"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
liquid_input_slot=[-1],
|
||||
liquid_type=liquid_type,
|
||||
liquid_volume=liquid_volume,
|
||||
slot_on_deck="",
|
||||
role=item.get("role", ""),
|
||||
)
|
||||
resource_last_writer[item_id] = f"{node_id}:labware"
|
||||
|
||||
last_control_node_id = None
|
||||
|
||||
# 处理协议步骤
|
||||
for step in protocol_steps:
|
||||
node_id = str(uuid.uuid4())
|
||||
G.add_node(node_id, **step)
|
||||
|
||||
# 控制流
|
||||
if last_control_node_id is not None:
|
||||
G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready")
|
||||
last_control_node_id = node_id
|
||||
|
||||
# 物料流
|
||||
params = step.get("parameters", {})
|
||||
input_resources = {
|
||||
"Vessel": params.get("vessel"),
|
||||
"ToVessel": params.get("to_vessel"),
|
||||
"FromVessel": params.get("from_vessel"),
|
||||
"reagent": params.get("reagent"),
|
||||
"solvent": params.get("solvent"),
|
||||
"compound": params.get("compound"),
|
||||
"sources": params.get("sources"),
|
||||
"targets": params.get("targets"),
|
||||
}
|
||||
|
||||
for target_port, resource_name in input_resources.items():
|
||||
if resource_name and resource_name in resource_last_writer:
|
||||
source_node, source_port = resource_last_writer[resource_name].split(":")
|
||||
G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port)
|
||||
|
||||
output_resources = {
|
||||
"VesselOut": params.get("vessel"),
|
||||
"FromVesselOut": params.get("from_vessel"),
|
||||
"ToVesselOut": params.get("to_vessel"),
|
||||
"FiltrateOut": params.get("filtrate_vessel"),
|
||||
"reagent": params.get("reagent"),
|
||||
"solvent": params.get("solvent"),
|
||||
"compound": params.get("compound"),
|
||||
"sources_out": params.get("sources"),
|
||||
"targets_out": params.get("targets"),
|
||||
}
|
||||
|
||||
for source_port, resource_name in output_resources.items():
|
||||
if resource_name:
|
||||
resource_last_writer[resource_name] = f"{node_id}:{source_port}"
|
||||
|
||||
return G
|
||||
|
||||
|
||||
def draw_protocol_graph(protocol_graph: SimpleGraph, output_path: str):
|
||||
"""
|
||||
(辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图,用于可视化。
|
||||
"""
|
||||
if not protocol_graph:
|
||||
print("Cannot draw graph: Graph object is empty.")
|
||||
return
|
||||
|
||||
G = nx.DiGraph()
|
||||
|
||||
for node_id, attrs in protocol_graph.nodes.items():
|
||||
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||
G.add_node(node_id, label=label, **attrs)
|
||||
|
||||
for edge in protocol_graph.edges:
|
||||
G.add_edge(edge["source"], edge["target"])
|
||||
|
||||
plt.figure(figsize=(20, 15))
|
||||
try:
|
||||
pos = nx.nx_agraph.graphviz_layout(G, prog="dot")
|
||||
except Exception:
|
||||
pos = nx.shell_layout(G) # Fallback layout
|
||||
|
||||
node_labels = {node: data["label"] for node, data in G.nodes(data=True)}
|
||||
nx.draw(
|
||||
G,
|
||||
pos,
|
||||
with_labels=False,
|
||||
node_size=2500,
|
||||
node_color="skyblue",
|
||||
node_shape="o",
|
||||
edge_color="gray",
|
||||
width=1.5,
|
||||
arrowsize=15,
|
||||
)
|
||||
nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=8, font_weight="bold")
|
||||
|
||||
plt.title("Chemical Protocol Workflow Graph", size=15)
|
||||
plt.savefig(output_path, dpi=300, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f" - Visualization saved to '{output_path}'")
|
||||
|
||||
|
||||
from networkx.drawing.nx_agraph import to_agraph
|
||||
import re
|
||||
|
||||
COMPASS = {"n","e","s","w","ne","nw","se","sw","c"}
|
||||
|
||||
def _is_compass(port: str) -> bool:
|
||||
return isinstance(port, str) and port.lower() in COMPASS
|
||||
|
||||
def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: str = "LR"):
|
||||
"""
|
||||
使用 Graphviz 端口语法绘制协议工作流图。
|
||||
- 若边上的 source_port/target_port 是 compass(n/e/s/w/...),直接用 compass。
|
||||
- 否则自动为节点创建 record 形状并定义命名端口 <portname>。
|
||||
最终由 PyGraphviz 渲染并输出到 output_path(后缀决定格式,如 .png/.svg/.pdf)。
|
||||
"""
|
||||
if not protocol_graph:
|
||||
print("Cannot draw graph: Graph object is empty.")
|
||||
return
|
||||
|
||||
# 1) 先用 networkx 搭建有向图,保留端口属性
|
||||
G = nx.DiGraph()
|
||||
for node_id, attrs in protocol_graph.nodes.items():
|
||||
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||
# 保留一个干净的“中心标签”,用于放在 record 的中间槽
|
||||
G.add_node(node_id, _core_label=str(label), **{k:v for k,v in attrs.items() if k not in ("label",)})
|
||||
|
||||
edges_data = []
|
||||
in_ports_by_node = {} # 收集命名输入端口
|
||||
out_ports_by_node = {} # 收集命名输出端口
|
||||
|
||||
for edge in protocol_graph.edges:
|
||||
u = edge["source"]
|
||||
v = edge["target"]
|
||||
sp = edge.get("source_port")
|
||||
tp = edge.get("target_port")
|
||||
|
||||
# 记录到图里(保留原始端口信息)
|
||||
G.add_edge(u, v, source_port=sp, target_port=tp)
|
||||
edges_data.append((u, v, sp, tp))
|
||||
|
||||
# 如果不是 compass,就按“命名端口”先归类,等会儿给节点造 record
|
||||
if sp and not _is_compass(sp):
|
||||
out_ports_by_node.setdefault(u, set()).add(str(sp))
|
||||
if tp and not _is_compass(tp):
|
||||
in_ports_by_node.setdefault(v, set()).add(str(tp))
|
||||
|
||||
# 2) 转为 AGraph,使用 Graphviz 渲染
|
||||
A = to_agraph(G)
|
||||
A.graph_attr.update(rankdir=rankdir, splines="true", concentrate="false", fontsize="10")
|
||||
A.node_attr.update(shape="box", style="rounded,filled", fillcolor="lightyellow", color="#999999", fontname="Helvetica")
|
||||
A.edge_attr.update(arrowsize="0.8", color="#666666")
|
||||
|
||||
# 3) 为需要命名端口的节点设置 record 形状与 label
|
||||
# 左列 = 输入端口;中间 = 核心标签;右列 = 输出端口
|
||||
for n in A.nodes():
|
||||
node = A.get_node(n)
|
||||
core = G.nodes[n].get("_core_label", n)
|
||||
|
||||
in_ports = sorted(in_ports_by_node.get(n, []))
|
||||
out_ports = sorted(out_ports_by_node.get(n, []))
|
||||
|
||||
# 如果该节点涉及命名端口,则用 record;否则保留原 box
|
||||
if in_ports or out_ports:
|
||||
def port_fields(ports):
|
||||
if not ports:
|
||||
return " " # 必须留一个空槽占位
|
||||
# 每个端口一个小格子,<p> name
|
||||
return "|".join(f"<{re.sub(r'[^A-Za-z0-9_:.|-]', '_', p)}> {p}" for p in ports)
|
||||
|
||||
left = port_fields(in_ports)
|
||||
right = port_fields(out_ports)
|
||||
|
||||
# 三栏:左(入) | 中(节点名) | 右(出)
|
||||
record_label = f"{{ {left} | {core} | {right} }}"
|
||||
node.attr.update(shape="record", label=record_label)
|
||||
else:
|
||||
# 没有命名端口:普通盒子,显示核心标签
|
||||
node.attr.update(label=str(core))
|
||||
|
||||
# 4) 给边设置 headport / tailport
|
||||
# - 若端口为 compass:直接用 compass(e.g., headport="e")
|
||||
# - 若端口为命名端口:使用在 record 中定义的 <port> 名(同名即可)
|
||||
for (u, v, sp, tp) in edges_data:
|
||||
e = A.get_edge(u, v)
|
||||
|
||||
# Graphviz 属性:tail 是源,head 是目标
|
||||
if sp:
|
||||
if _is_compass(sp):
|
||||
e.attr["tailport"] = sp.lower()
|
||||
else:
|
||||
# 与 record label 中 <port> 名一致;特殊字符已在 label 中做了清洗
|
||||
e.attr["tailport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(sp))
|
||||
|
||||
if tp:
|
||||
if _is_compass(tp):
|
||||
e.attr["headport"] = tp.lower()
|
||||
else:
|
||||
e.attr["headport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(tp))
|
||||
|
||||
# 可选:若想让边更贴边缘,可设置 constraint/spline 等
|
||||
# e.attr["arrowhead"] = "vee"
|
||||
|
||||
# 5) 输出
|
||||
A.draw(output_path, prog="dot")
|
||||
print(f" - Port-aware workflow rendered to '{output_path}'")
|
||||
|
||||
|
||||
def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]:
|
||||
"""展平嵌套的XDL程序结构"""
|
||||
flattened_operations = []
|
||||
TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||
|
||||
def extract_operations(element: ET.Element):
|
||||
if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]:
|
||||
if element.tag not in TEMP_UNSUPPORTED_PROTOCOL:
|
||||
flattened_operations.append(element)
|
||||
|
||||
for child in element:
|
||||
extract_operations(child)
|
||||
|
||||
for child in procedure_elem:
|
||||
extract_operations(child)
|
||||
|
||||
return flattened_operations
|
||||
|
||||
|
||||
def parse_xdl_content(xdl_content: str) -> tuple:
|
||||
"""解析XDL内容"""
|
||||
try:
|
||||
xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable())
|
||||
root = ET.fromstring(xdl_content_cleaned)
|
||||
|
||||
synthesis_elem = root.find("Synthesis")
|
||||
if synthesis_elem is None:
|
||||
return None, None, None
|
||||
|
||||
# 解析硬件组件
|
||||
hardware_elem = synthesis_elem.find("Hardware")
|
||||
hardware = []
|
||||
if hardware_elem is not None:
|
||||
hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")]
|
||||
|
||||
# 解析试剂
|
||||
reagents_elem = synthesis_elem.find("Reagents")
|
||||
reagents = []
|
||||
if reagents_elem is not None:
|
||||
reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")]
|
||||
|
||||
# 解析程序
|
||||
procedure_elem = synthesis_elem.find("Procedure")
|
||||
if procedure_elem is None:
|
||||
return None, None, None
|
||||
|
||||
flattened_operations = flatten_xdl_procedure(procedure_elem)
|
||||
return hardware, reagents, flattened_operations
|
||||
|
||||
except ET.ParseError as e:
|
||||
raise ValueError(f"Invalid XDL format: {e}")
|
||||
|
||||
|
||||
def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]:
|
||||
"""
|
||||
将XDL XML格式转换为标准的字典格式
|
||||
|
||||
Args:
|
||||
xdl_content: XDL XML内容
|
||||
|
||||
Returns:
|
||||
转换结果,包含步骤和器材信息
|
||||
"""
|
||||
try:
|
||||
hardware, reagents, flattened_operations = parse_xdl_content(xdl_content)
|
||||
if hardware is None:
|
||||
return {"error": "Failed to parse XDL content", "success": False}
|
||||
|
||||
# 将XDL元素转换为字典格式
|
||||
steps_data = []
|
||||
for elem in flattened_operations:
|
||||
# 转换参数类型
|
||||
parameters = {}
|
||||
for key, val in elem.attrib.items():
|
||||
converted_val = convert_to_type(val)
|
||||
if converted_val is not None:
|
||||
parameters[key] = converted_val
|
||||
|
||||
step_dict = {
|
||||
"operation": elem.tag,
|
||||
"parameters": parameters,
|
||||
"description": elem.get("purpose", f"Operation: {elem.tag}"),
|
||||
}
|
||||
steps_data.append(step_dict)
|
||||
|
||||
# 合并硬件和试剂为统一的labware_info格式
|
||||
labware_data = []
|
||||
labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware)
|
||||
labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"steps": steps_data,
|
||||
"labware": labware_data,
|
||||
"message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"XDL conversion failed: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg, "success": False}
|
||||
|
||||
|
||||
def create_workflow(
|
||||
steps_info: str,
|
||||
labware_info: str,
|
||||
workflow_name: str = "Generated Workflow",
|
||||
workstation_name: str = "workstation",
|
||||
workflow_description: str = "Auto-generated workflow from protocol",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
创建工作流,输入数据已经是统一的字典格式
|
||||
|
||||
Args:
|
||||
steps_info: 步骤信息 (JSON字符串,已经是list of dict格式)
|
||||
labware_info: 实验器材和试剂信息 (JSON字符串,已经是list of dict格式)
|
||||
workflow_name: 工作流名称
|
||||
workflow_description: 工作流描述
|
||||
|
||||
Returns:
|
||||
创建结果,包含工作流UUID和详细信息
|
||||
"""
|
||||
try:
|
||||
# 直接解析JSON数据
|
||||
steps_info_clean = extract_json_from_markdown(steps_info)
|
||||
labware_info_clean = extract_json_from_markdown(labware_info)
|
||||
|
||||
steps_data = json.loads(steps_info_clean)
|
||||
labware_data = json.loads(labware_info_clean)
|
||||
|
||||
# 统一处理所有数据
|
||||
protocol_graph = build_protocol_graph(labware_data, steps_data, workstation_name=workstation_name)
|
||||
|
||||
# 检测协议类型(用于标签)
|
||||
protocol_type = "bio" if any("biomek" in step.get("template", "") for step in refactored_steps) else "organic"
|
||||
|
||||
# 转换为工作流格式
|
||||
data = protocol_graph.to_dict()
|
||||
|
||||
# 转换节点格式
|
||||
for i, node in enumerate(data["nodes"]):
|
||||
description = node.get("description", "")
|
||||
onode = {
|
||||
"template": node.pop("template"),
|
||||
"id": node["id"],
|
||||
"lab_node_type": node.get("lab_node_type", "Device"),
|
||||
"name": description or f"Node {i + 1}",
|
||||
"params": {"default": node},
|
||||
"handles": {},
|
||||
}
|
||||
|
||||
# 处理边连接
|
||||
for edge in data["links"]:
|
||||
if edge["source"] == node["id"]:
|
||||
source_port = edge.get("source_port", "output")
|
||||
if source_port not in onode["handles"]:
|
||||
onode["handles"][source_port] = {"type": "source"}
|
||||
|
||||
if edge["target"] == node["id"]:
|
||||
target_port = edge.get("target_port", "input")
|
||||
if target_port not in onode["handles"]:
|
||||
onode["handles"][target_port] = {"type": "target"}
|
||||
|
||||
data["nodes"][i] = onode
|
||||
|
||||
# 发送到API创建工作流
|
||||
api_secret = configs.Lab.Key
|
||||
if not api_secret:
|
||||
return {"error": "API SecretKey is not configured", "success": False}
|
||||
|
||||
# Step 1: 创建工作流
|
||||
workflow_url = f"{configs.Lab.Api}/api/v1/workflow/"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
params = {"secret_key": api_secret}
|
||||
|
||||
graph_data = {"name": workflow_name, **data}
|
||||
|
||||
logger.info(f"Creating workflow: {workflow_name}")
|
||||
response = requests.post(
|
||||
workflow_url, params=params, json=graph_data, headers=headers, timeout=configs.Lab.Timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
workflow_info = response.json()
|
||||
|
||||
if workflow_info.get("code") != 0:
|
||||
error_msg = f"API returned an error: {workflow_info.get('msg', 'Unknown Error')}"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg, "success": False}
|
||||
|
||||
workflow_uuid = workflow_info.get("data", {}).get("uuid")
|
||||
if not workflow_uuid:
|
||||
return {"error": "Failed to get workflow UUID from response", "success": False}
|
||||
|
||||
# Step 2: 添加到模板库(可选)
|
||||
try:
|
||||
library_url = f"{configs.Lab.Api}/api/flociety/vs/workflows/library/"
|
||||
lib_payload = {
|
||||
"workflow_uuid": workflow_uuid,
|
||||
"title": workflow_name,
|
||||
"description": workflow_description,
|
||||
"labels": [protocol_type.title(), "Auto-generated"],
|
||||
}
|
||||
|
||||
library_response = requests.post(
|
||||
library_url, params=params, json=lib_payload, headers=headers, timeout=configs.Lab.Timeout
|
||||
)
|
||||
library_response.raise_for_status()
|
||||
|
||||
library_info = library_response.json()
|
||||
logger.info(f"Workflow added to library: {library_info}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"workflow_uuid": workflow_uuid,
|
||||
"workflow_info": workflow_info.get("data"),
|
||||
"library_info": library_info.get("data"),
|
||||
"protocol_type": protocol_type,
|
||||
"message": f"Workflow '{workflow_name}' created successfully",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# 即使添加到库失败,工作流创建仍然成功
|
||||
logger.warning(f"Failed to add workflow to library: {str(e)}")
|
||||
return {
|
||||
"success": True,
|
||||
"workflow_uuid": workflow_uuid,
|
||||
"workflow_info": workflow_info.get("data"),
|
||||
"protocol_type": protocol_type,
|
||||
"message": f"Workflow '{workflow_name}' created successfully (library addition failed)",
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = f"Network error when calling API: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg, "success": False}
|
||||
except json.JSONDecodeError as e:
|
||||
error_msg = f"JSON parsing error: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg, "success": False}
|
||||
except Exception as e:
|
||||
error_msg = f"An unexpected error occurred: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
logger.error(traceback.format_exc())
|
||||
return {"error": error_msg, "success": False}
|
||||
10
setup.py
@@ -4,20 +4,20 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.4',
|
||||
version='0.10.11',
|
||||
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"
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "OrganicSynthesisStation",
|
||||
"name": "有机化学流程综合测试工作站",
|
||||
"children": [
|
||||
"heater_1"
|
||||
],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "workstation",
|
||||
"position": {
|
||||
"x": 600,
|
||||
"y": 400,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"protocol_type": [
|
||||
"AddProtocol",
|
||||
"TransferProtocol",
|
||||
"StartStirProtocol",
|
||||
"StopStirProtocol",
|
||||
"StirProtocol",
|
||||
"RunColumnProtocol",
|
||||
"CentrifugeProtocol",
|
||||
"FilterProtocol",
|
||||
"CleanVesselProtocol",
|
||||
"DissolveProtocol",
|
||||
"FilterThroughProtocol",
|
||||
"WashSolidProtocol",
|
||||
"SeparateProtocol",
|
||||
"EvaporateProtocol",
|
||||
"HeatChillProtocol",
|
||||
"HeatChillStartProtocol",
|
||||
"HeatChillStopProtocol",
|
||||
"EvacuateAndRefillProtocol",
|
||||
"PumpTransferProtocol",
|
||||
"AdjustPHProtocol",
|
||||
"ResetHandlingProtocol",
|
||||
"DryProtocol",
|
||||
"HydrogenateProtocol",
|
||||
"RecrystallizeProtocol"
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "heater_1",
|
||||
"name": "加热器",
|
||||
"children": [],
|
||||
"parent": "OrganicSynthesisStation",
|
||||
"type": "device",
|
||||
"class": "virtual_heatchill",
|
||||
"position": {
|
||||
"x": 450,
|
||||
"y": 450,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_temp": 200.0,
|
||||
"min_temp": -20.0
|
||||
},
|
||||
"data": {
|
||||
"status": "Idle",
|
||||
"current_temp": 25.0
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = "0.10.11"
|
||||
|
||||
@@ -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,
|
||||
is_slave: 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),
|
||||
target=main if not is_slave else slave,
|
||||
args=(
|
||||
devices_config,
|
||||
resources_config,
|
||||
resources_edge_config,
|
||||
graph,
|
||||
controllers_config,
|
||||
bridges,
|
||||
visual,
|
||||
resources_mesh_config,
|
||||
),
|
||||
name="backend_thread",
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
192
unilabos/app/communication.py
Normal 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)
|
||||
@@ -1,45 +0,0 @@
|
||||
|
||||
import json
|
||||
import traceback
|
||||
import uuid
|
||||
from unilabos.app.model import JobAddReq, JobData
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
from unilabos.utils.type_check import serialize_result_info
|
||||
|
||||
|
||||
def get_resources() -> tuple:
|
||||
if HostNode.get_instance() is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, HostNode.get_instance().resources_config
|
||||
|
||||
def devices() -> tuple:
|
||||
if HostNode.get_instance() is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, HostNode.get_instance().devices_config
|
||||
|
||||
def job_info(id: str):
|
||||
get_goal_status = HostNode.get_instance().get_goal_status(id)
|
||||
return JobData(jobId=id, status=get_goal_status)
|
||||
|
||||
def job_add(req: JobAddReq) -> JobData:
|
||||
if req.job_id is None:
|
||||
req.job_id = str(uuid.uuid4())
|
||||
action_name = req.data["action"]
|
||||
action_type = req.data.get("action_type", "LocalUnknown")
|
||||
action_args = req.data.get("action_kwargs", None) # 兼容老版本,后续删除
|
||||
if action_args is None:
|
||||
action_args = req.data.get("action_args")
|
||||
else:
|
||||
if "command" in action_args:
|
||||
action_args = action_args["command"]
|
||||
# print(f"job_add:{req.device_id} {action_name} {action_kwargs}")
|
||||
try:
|
||||
HostNode.get_instance().send_goal(req.device_id, action_type=action_type, action_name=action_name, action_kwargs=action_args, goal_uuid=req.job_id, server_info=req.server_info)
|
||||
except Exception as e:
|
||||
for bridge in HostNode.get_instance().bridges:
|
||||
traceback.print_exc()
|
||||
if hasattr(bridge, "publish_job_status"):
|
||||
bridge.publish_job_status({}, req.job_id, "failed", serialize_result_info(traceback.format_exc(), False, {}))
|
||||
return JobData(jobId=req.job_id)
|
||||
@@ -6,23 +6,21 @@ 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
|
||||
|
||||
# 首先添加项目根目录到路径
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
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.utils.banner_print import print_status, print_unilab_banner
|
||||
from unilabos.config.config import load_config, BasicConfig, HTTPConfig
|
||||
|
||||
|
||||
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 +29,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 +41,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 +49,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 +73,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服务的启动端口",
|
||||
default=None,
|
||||
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
|
||||
|
||||
@@ -171,8 +180,9 @@ def main():
|
||||
working_dir = os.path.abspath(os.getcwd())
|
||||
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 +207,44 @@ 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)
|
||||
|
||||
# 根据配置重新设置日志级别
|
||||
from unilabos.utils.log import configure_logger, logger
|
||||
|
||||
if hasattr(BasicConfig, "log_level"):
|
||||
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
|
||||
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
||||
|
||||
if args_dict["addr"] == "test":
|
||||
print_status("使用测试环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
||||
elif args_dict["addr"] == "uat":
|
||||
print_status("使用uat环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
|
||||
elif args_dict["addr"] == "local":
|
||||
print_status("使用本地环境地址", "info")
|
||||
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||
else:
|
||||
HTTPConfig.remote_addr = args_dict.get("addr", "")
|
||||
|
||||
# 设置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 +252,13 @@ def main():
|
||||
else:
|
||||
print_status("远程资源不存在,本地将进行首次上报!", "info")
|
||||
|
||||
# 设置BasicConfig参数
|
||||
BasicConfig.port = args_dict["port"] if args_dict["port"] else BasicConfig.port
|
||||
BasicConfig.disable_browser = args_dict["disable_browser"] or BasicConfig.disable_browser
|
||||
BasicConfig.working_dir = working_dir
|
||||
BasicConfig.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 +268,34 @@ 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
|
||||
from unilabos.resources.graphio import modify_to_backend_format
|
||||
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet, ResourceDict
|
||||
|
||||
# 显示启动横幅
|
||||
print_unilab_banner(args_dict)
|
||||
|
||||
# 注册表
|
||||
build_registry(args_dict["registry_path"], False, args_dict["upload_registry"])
|
||||
if args_dict["graph"] is None:
|
||||
request_startup_json = http_client.request_startup_json()
|
||||
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()
|
||||
|
||||
file_path = args_dict.get("graph", BasicConfig.startup_json_path)
|
||||
if file_path is None:
|
||||
if not request_startup_json:
|
||||
print_status(
|
||||
"未指定设备加载文件路径,尝试从HTTP获取失败,请检查网络或者使用-g参数指定设备加载文件路径", "error"
|
||||
@@ -253,26 +303,78 @@ 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 not os.path.isfile(file_path):
|
||||
temp_file_path = os.path.abspath(str(os.path.join(__file__, "..", "..", file_path)))
|
||||
if os.path.isfile(temp_file_path):
|
||||
print_status(f"使用相对路径{temp_file_path}", "info")
|
||||
file_path = temp_file_path
|
||||
if file_path.endswith(".json"):
|
||||
graph, 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 BasicConfig.ak and BasicConfig.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,57 +383,80 @@ def main():
|
||||
|
||||
args_dict["bridges"] = []
|
||||
|
||||
if "mqtt" in args_dict["app_bridges"]:
|
||||
args_dict["bridges"].append(mqtt_client)
|
||||
if "fastapi" in args_dict["app_bridges"]:
|
||||
args_dict["bridges"].append(http_client)
|
||||
if "mqtt" in args_dict["app_bridges"]:
|
||||
# 获取通信客户端(仅支持WebSocket)
|
||||
if BasicConfig.is_host_mode:
|
||||
comm_client = get_communication_client()
|
||||
if "websocket" in args_dict["app_bridges"]:
|
||||
args_dict["bridges"].append(comm_client)
|
||||
def _exit(signum, frame):
|
||||
comm_client.stop()
|
||||
sys.exit(0)
|
||||
|
||||
def _exit(signum, frame):
|
||||
mqtt_client.stop()
|
||||
sys.exit(0)
|
||||
signal.signal(signal.SIGINT, _exit)
|
||||
signal.signal(signal.SIGTERM, _exit)
|
||||
comm_client.start()
|
||||
else:
|
||||
print_status("SlaveMode跳过Websocket连接")
|
||||
|
||||
signal.signal(signal.SIGINT, _exit)
|
||||
signal.signal(signal.SIGTERM, _exit)
|
||||
mqtt_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)
|
||||
server_thread = threading.Thread(
|
||||
target=start_server,
|
||||
kwargs=dict(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=args_dict["port"],
|
||||
open_browser=not BasicConfig.disable_browser,
|
||||
port=BasicConfig.port,
|
||||
),
|
||||
)
|
||||
server_thread.start()
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
resource_visualization.start()
|
||||
try:
|
||||
resource_visualization.start()
|
||||
except OSError as e:
|
||||
if "AMENT_PREFIX_PATH" in str(e):
|
||||
print_status(
|
||||
f"ROS 2环境未正确设置,跳过3D可视化启动。错误详情: {e}",
|
||||
"warning"
|
||||
)
|
||||
print_status(
|
||||
"建议解决方案:\n"
|
||||
"1. 激活Conda环境: conda activate unilab\n"
|
||||
"2. 或使用 --backend simple 参数\n"
|
||||
"3. 或使用 --visual disable 参数禁用可视化",
|
||||
"info"
|
||||
)
|
||||
else:
|
||||
raise
|
||||
while True:
|
||||
time.sleep(1)
|
||||
else:
|
||||
start_backend(**args_dict)
|
||||
start_server(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=args_dict["port"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
else:
|
||||
start_backend(**args_dict)
|
||||
start_server(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=args_dict["port"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -50,17 +50,26 @@ 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"}])
|
||||
job_id: str = Field(examples=["job_id"], description="goal uuid")
|
||||
node_id: str = Field(examples=["node_id"], description="node uuid")
|
||||
server_info: dict = Field(examples=[{"send_timestamp": 1717000000.0}], description="server info")
|
||||
action: 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 type", default=""
|
||||
)
|
||||
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
|
||||
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
|
||||
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
|
||||
node_id: str = Field(examples=["node_id"], description="node uuid", default="")
|
||||
server_info: dict = Field(
|
||||
examples=[{"send_timestamp": 1717000000.0}],
|
||||
description="server info (auto-generated if empty)",
|
||||
default_factory=dict,
|
||||
)
|
||||
|
||||
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}], default_factory=dict)
|
||||
|
||||
|
||||
class JobStepFinishReq(BaseModel):
|
||||
token: str = Field(examples=["030944"], description="token")
|
||||
request_time: str = Field(
|
||||
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
|
||||
)
|
||||
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
|
||||
data: dict = Field(
|
||||
examples=[
|
||||
{
|
||||
@@ -78,9 +87,7 @@ class JobStepFinishReq(BaseModel):
|
||||
|
||||
class JobPreintakeFinishReq(BaseModel):
|
||||
token: str = Field(examples=["030944"], description="token")
|
||||
request_time: str = Field(
|
||||
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
|
||||
)
|
||||
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
|
||||
data: dict = Field(
|
||||
examples=[
|
||||
{
|
||||
@@ -97,9 +104,7 @@ class JobPreintakeFinishReq(BaseModel):
|
||||
|
||||
class JobFinishReq(BaseModel):
|
||||
token: str = Field(examples=["030944"], description="token")
|
||||
request_time: str = Field(
|
||||
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
|
||||
)
|
||||
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
|
||||
data: dict = Field(
|
||||
examples=[
|
||||
{
|
||||
@@ -128,6 +133,10 @@ class JobData(BaseModel):
|
||||
default=0,
|
||||
description="0:UNKNOWN, 1:ACCEPTED, 2:EXECUTING, 3:CANCELING, 4:SUCCEEDED, 5:CANCELED, 6:ABORTED",
|
||||
)
|
||||
result: dict = Field(
|
||||
default_factory=dict,
|
||||
description="Job result data (available when status is SUCCEEDED/CANCELED/ABORTED)",
|
||||
)
|
||||
|
||||
|
||||
class JobStatusResp(Resp):
|
||||
|
||||
@@ -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()
|
||||
@@ -1,161 +1,158 @@
|
||||
import argparse
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, Optional, Tuple
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
|
||||
from unilabos.config.config import OSSUploadConfig
|
||||
from unilabos.app.web.client import http_client, HTTPClient
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
def _init_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
|
||||
process_key: str = "file-upload", device_id: str = "default",
|
||||
expires_hours: int = 1) -> Tuple[bool, Dict]:
|
||||
def _get_oss_token(
|
||||
filename: str,
|
||||
driver_name: str = "default",
|
||||
exp_type: str = "default",
|
||||
client: Optional[HTTPClient] = None,
|
||||
) -> Tuple[bool, Dict]:
|
||||
"""
|
||||
初始化上传过程
|
||||
获取OSS上传Token
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
oss_path: OSS目标路径
|
||||
filename: 文件名,如果为None则使用file_path的文件名
|
||||
process_key: 处理键
|
||||
device_id: 设备ID
|
||||
expires_hours: 链接过期小时数
|
||||
filename: 文件名
|
||||
driver_name: 驱动名称
|
||||
exp_type: 实验类型
|
||||
client: HTTPClient实例,如果不提供则使用默认的http_client
|
||||
|
||||
Returns:
|
||||
(成功标志, 响应数据)
|
||||
(成功标志, Token数据字典包含token/path/host/expires)
|
||||
"""
|
||||
if filename is None:
|
||||
filename = os.path.basename(file_path)
|
||||
# 使用提供的client或默认的http_client
|
||||
if client is None:
|
||||
client = http_client
|
||||
|
||||
# 构造初始化请求
|
||||
url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.init_endpoint}"
|
||||
headers = {
|
||||
"Authorization": OSSUploadConfig.authorization,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
# 构造scene参数: driver_name-exp_type
|
||||
scene = f"{driver_name}-{exp_type}"
|
||||
|
||||
payload = {
|
||||
"device_id": device_id,
|
||||
"process_key": process_key,
|
||||
"filename": filename,
|
||||
"path": oss_path,
|
||||
"expires_hours": expires_hours
|
||||
}
|
||||
# 构造请求URL,使用client的remote_addr(已包含/api/v1/)
|
||||
url = f"{client.remote_addr}/applications/token"
|
||||
params = {"scene": scene, "filename": filename}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
if response.status_code == 201:
|
||||
result = response.json()
|
||||
if result.get("code") == "10000":
|
||||
return True, result.get("data", {})
|
||||
logger.info(f"[OSS] 请求预签名URL: scene={scene}, filename={filename}")
|
||||
response = requests.get(url, params=params, headers={"Authorization": f"Lab {client.auth}"}, timeout=10)
|
||||
|
||||
print(f"初始化上传失败: {response.status_code}, {response.text}")
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get("code") == 0:
|
||||
data = result.get("data", {})
|
||||
|
||||
# 转换expires时间戳为可读格式
|
||||
expires_timestamp = data.get("expires", 0)
|
||||
expires_datetime = datetime.fromtimestamp(expires_timestamp)
|
||||
expires_str = expires_datetime.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
logger.info(f"[OSS] 获取预签名URL成功")
|
||||
logger.info(f"[OSS] - URL: {data.get('url', 'N/A')}")
|
||||
logger.info(f"[OSS] - Expires: {expires_str} (timestamp: {expires_timestamp})")
|
||||
|
||||
return True, data
|
||||
|
||||
logger.error(f"[OSS] 获取预签名URL失败: {response.status_code}, {response.text}")
|
||||
return False, {}
|
||||
except Exception as e:
|
||||
print(f"初始化上传异常: {str(e)}")
|
||||
logger.error(f"[OSS] 获取预签名URL异常: {str(e)}")
|
||||
return False, {}
|
||||
|
||||
|
||||
def _put_upload(file_path: str, upload_url: str) -> bool:
|
||||
"""
|
||||
执行PUT上传
|
||||
使用预签名URL上传文件到OSS
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
upload_url: 上传URL
|
||||
upload_url: 完整的预签名上传URL
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
logger.info(f"[OSS] 开始上传文件: {file_path}")
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
response = requests.put(upload_url, data=f)
|
||||
# 使用预签名URL上传,不需要额外的认证header
|
||||
response = requests.put(upload_url, data=f, timeout=300)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"[OSS] 文件上传成功")
|
||||
return True
|
||||
|
||||
print(f"PUT上传失败: {response.status_code}, {response.text}")
|
||||
logger.error(f"[OSS] 上传失败: {response.status_code}")
|
||||
logger.error(f"[OSS] 响应内容: {response.text[:500] if response.text else '无响应内容'}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"PUT上传异常: {str(e)}")
|
||||
logger.error(f"[OSS] 上传异常: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def _complete_upload(uuid: str) -> bool:
|
||||
"""
|
||||
完成上传过程
|
||||
|
||||
Args:
|
||||
uuid: 上传的UUID
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.complete_endpoint}"
|
||||
headers = {
|
||||
"Authorization": OSSUploadConfig.authorization,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"uuid": uuid
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get("code") == "10000":
|
||||
return True
|
||||
|
||||
print(f"完成上传失败: {response.status_code}, {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"完成上传异常: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def oss_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
|
||||
process_key: str = "file-upload", device_id: str = "default") -> bool:
|
||||
def oss_upload(
|
||||
file_path: Union[str, Path],
|
||||
filename: Optional[str] = None,
|
||||
driver_name: str = "default",
|
||||
exp_type: str = "default",
|
||||
max_retries: int = 3,
|
||||
client: Optional[HTTPClient] = None,
|
||||
) -> Dict:
|
||||
"""
|
||||
文件上传主函数,包含重试机制
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
oss_path: OSS目标路径
|
||||
filename: 文件名,如果为None则使用file_path的文件名
|
||||
process_key: 处理键
|
||||
device_id: 设备ID
|
||||
driver_name: 驱动名称,用于构造scene
|
||||
exp_type: 实验类型,用于构造scene
|
||||
max_retries: 最大重试次数
|
||||
client: HTTPClient实例,如果不提供则使用默认的http_client
|
||||
|
||||
Returns:
|
||||
是否成功上传
|
||||
Dict: {
|
||||
"success": bool, # 是否上传成功
|
||||
"original_path": str, # 原始文件路径
|
||||
"oss_path": str # OSS路径(成功时)或空字符串(失败时)
|
||||
}
|
||||
"""
|
||||
max_retries = OSSUploadConfig.max_retries
|
||||
file_path = Path(file_path)
|
||||
if filename is None:
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"[OSS] 文件不存在: {file_path}")
|
||||
return {"success": False, "original_path": file_path, "oss_path": ""}
|
||||
|
||||
retry_count = 0
|
||||
oss_path = ""
|
||||
|
||||
while retry_count < max_retries:
|
||||
try:
|
||||
# 步骤1:初始化上传
|
||||
init_success, init_data = _init_upload(
|
||||
file_path=file_path,
|
||||
oss_path=oss_path,
|
||||
filename=filename,
|
||||
process_key=process_key,
|
||||
device_id=device_id
|
||||
# 步骤1:获取预签名URL
|
||||
token_success, token_data = _get_oss_token(
|
||||
filename=filename, driver_name=driver_name, exp_type=exp_type, client=client
|
||||
)
|
||||
|
||||
if not init_success:
|
||||
print(f"初始化上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
if not token_success:
|
||||
logger.warning(f"[OSS] 获取预签名URL失败,重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1) # 等待1秒后重试
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# 获取UUID和上传URL
|
||||
uuid = init_data.get("uuid")
|
||||
upload_url = init_data.get("upload_url")
|
||||
# 获取预签名URL和OSS路径
|
||||
upload_url = token_data.get("url")
|
||||
oss_path = token_data.get("path", "")
|
||||
|
||||
if not uuid or not upload_url:
|
||||
print(f"初始化上传返回数据不完整,重试 {retry_count + 1}/{max_retries}")
|
||||
if not upload_url:
|
||||
logger.warning(f"[OSS] 无法获取上传URL,API未返回url字段")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
@@ -163,69 +160,82 @@ def oss_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
|
||||
# 步骤2:PUT上传文件
|
||||
put_success = _put_upload(file_path, upload_url)
|
||||
if not put_success:
|
||||
print(f"PUT上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# 步骤3:完成上传
|
||||
complete_success = _complete_upload(uuid)
|
||||
if not complete_success:
|
||||
print(f"完成上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
logger.warning(f"[OSS] PUT上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# 所有步骤都成功
|
||||
print(f"文件 {file_path} 上传成功")
|
||||
return True
|
||||
logger.info(f"[OSS] 文件 {file_path} 上传成功")
|
||||
return {"success": True, "original_path": file_path, "oss_path": oss_path}
|
||||
|
||||
except Exception as e:
|
||||
print(f"上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}")
|
||||
logger.error(f"[OSS] 上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
|
||||
print(f"文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}")
|
||||
return False
|
||||
logger.error(f"[OSS] 文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}")
|
||||
return {"success": False, "original_path": file_path, "oss_path": oss_path}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt
|
||||
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt --driver HPLC --type test
|
||||
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt --driver HPLC --type test \
|
||||
# --ak xxx --sk yyy --remote-addr http://xxx/api/v1
|
||||
# 命令行参数解析
|
||||
parser = argparse.ArgumentParser(description='文件上传测试工具')
|
||||
parser.add_argument('--file', '-f', type=str, required=True, help='要上传的本地文件路径')
|
||||
parser.add_argument('--path', '-p', type=str, default='/HPLC1/Any', help='OSS目标路径')
|
||||
parser.add_argument('--device', '-d', type=str, default='test-device', help='设备ID')
|
||||
parser.add_argument('--process', '-k', type=str, default='HPLC-txt-result', help='处理键')
|
||||
parser = argparse.ArgumentParser(description="文件上传测试工具")
|
||||
parser.add_argument("--file", "-f", type=str, required=True, help="要上传的本地文件路径")
|
||||
parser.add_argument("--driver", "-d", type=str, default="default", help="驱动名称")
|
||||
parser.add_argument("--type", "-t", type=str, default="default", help="实验类型")
|
||||
parser.add_argument("--ak", type=str, help="Access Key,如果提供则覆盖配置")
|
||||
parser.add_argument("--sk", type=str, help="Secret Key,如果提供则覆盖配置")
|
||||
parser.add_argument("--remote-addr", type=str, help="远程服务器地址(包含/api/v1),如果提供则覆盖配置")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 检查文件是否存在
|
||||
if not os.path.exists(args.file):
|
||||
print(f"错误:文件 {args.file} 不存在")
|
||||
logger.error(f"错误:文件 {args.file} 不存在")
|
||||
exit(1)
|
||||
|
||||
print("=" * 50)
|
||||
print(f"开始上传文件: {args.file}")
|
||||
print(f"目标路径: {args.path}")
|
||||
print(f"设备ID: {args.device}")
|
||||
print(f"处理键: {args.process}")
|
||||
print("=" * 50)
|
||||
# 如果提供了ak/sk/remote_addr,创建临时HTTPClient
|
||||
temp_client = None
|
||||
if args.ak and args.sk:
|
||||
import base64
|
||||
|
||||
auth = base64.b64encode(f"{args.ak}:{args.sk}".encode("utf-8")).decode("utf-8")
|
||||
remote_addr = args.remote_addr if args.remote_addr else http_client.remote_addr
|
||||
temp_client = HTTPClient(remote_addr=remote_addr, auth=auth)
|
||||
logger.info(f"[配置] 使用自定义配置: remote_addr={remote_addr}")
|
||||
elif args.remote_addr:
|
||||
temp_client = HTTPClient(remote_addr=args.remote_addr, auth=http_client.auth)
|
||||
logger.info(f"[配置] 使用自定义remote_addr: {args.remote_addr}")
|
||||
else:
|
||||
logger.info(f"[配置] 使用默认配置: remote_addr={http_client.remote_addr}")
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info(f"开始上传文件: {args.file}")
|
||||
logger.info(f"驱动名称: {args.driver}")
|
||||
logger.info(f"实验类型: {args.type}")
|
||||
logger.info(f"Scene: {args.driver}-{args.type}")
|
||||
logger.info("=" * 50)
|
||||
|
||||
# 执行上传
|
||||
success = oss_upload(
|
||||
result = oss_upload(
|
||||
file_path=args.file,
|
||||
oss_path=args.path,
|
||||
filename=None, # 使用默认文件名
|
||||
process_key=args.process,
|
||||
device_id=args.device
|
||||
driver_name=args.driver,
|
||||
exp_type=args.type,
|
||||
client=temp_client,
|
||||
)
|
||||
|
||||
# 输出结果
|
||||
if success:
|
||||
print("\n√ 文件上传成功!")
|
||||
if result["success"]:
|
||||
logger.info(f"\n√ 文件上传成功!")
|
||||
logger.info(f"原始路径: {result['original_path']}")
|
||||
logger.info(f"OSS路径: {result['oss_path']}")
|
||||
exit(0)
|
||||
else:
|
||||
print("\n× 文件上传失败!")
|
||||
logger.error(f"\n× 文件上传失败!")
|
||||
logger.error(f"原始路径: {result['original_path']}")
|
||||
exit(1)
|
||||
|
||||
|
||||
@@ -1,85 +1,60 @@
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from typing import Optional, Tuple, Dict, Any
|
||||
|
||||
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, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
|
||||
"""
|
||||
注册设备和资源到 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 gather_only:
|
||||
return devices_to_register, resources_to_register
|
||||
# 注册设备
|
||||
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()
|
||||
|
||||
@@ -3,13 +3,17 @@ HTTP客户端模块
|
||||
|
||||
提供与远程服务器通信的客户端功能,只有host需要用
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from threading import Thread
|
||||
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 +28,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 +48,136 @@ 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}
|
||||
"""
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps({"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, indent=4))
|
||||
# 从序列化数据中提取所有节点的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=60,
|
||||
)
|
||||
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=10,
|
||||
)
|
||||
|
||||
with open(os.path.join(BasicConfig.working_dir, "res_resource_tree_add.json"), "w", encoding="utf-8") as f:
|
||||
f.write(f"{response.status_code}" + "\n" + response.text)
|
||||
# 处理响应,构建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}
|
||||
"""
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4))
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/edge/material/query",
|
||||
json={"uuids": uuid_list, "with_children": with_children},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=100,
|
||||
)
|
||||
with open(os.path.join(BasicConfig.working_dir, "res_resource_tree_get.json"), "w", encoding="utf-8") as f:
|
||||
f.write(f"{response.status_code}" + "\n" + response.text)
|
||||
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
|
||||
@@ -83,12 +193,16 @@ class HTTPClient:
|
||||
Returns:
|
||||
Dict: 返回的资源数据
|
||||
"""
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps({"id": id, "with_children": with_children}, indent=4))
|
||||
response = requests.get(
|
||||
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,
|
||||
)
|
||||
with open(os.path.join(BasicConfig.working_dir, "res_resource_get.json"), "w", encoding="utf-8") as f:
|
||||
f.write(f"{response.status_code}" + "\n" + response.text)
|
||||
return response.json()
|
||||
|
||||
def resource_del(self, id: str) -> requests.Response:
|
||||
@@ -104,7 +218,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 +233,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 +276,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 +312,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:
|
||||
|
||||
587
unilabos/app/web/controller.py
Normal file
@@ -0,0 +1,587 @@
|
||||
"""
|
||||
Web API Controller
|
||||
|
||||
提供Web API的控制器函数,处理设备、任务和动作相关的业务逻辑
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from unilabos.app.model import JobAddReq, JobData
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobResult:
|
||||
"""任务结果数据"""
|
||||
|
||||
job_id: str
|
||||
status: int # 4:SUCCEEDED, 5:CANCELED, 6:ABORTED
|
||||
result: Dict[str, Any] = field(default_factory=dict)
|
||||
feedback: Dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
class JobResultStore:
|
||||
"""任务结果存储(单例)"""
|
||||
|
||||
_instance: Optional["JobResultStore"] = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __init__(self):
|
||||
if not hasattr(self, "_initialized"):
|
||||
self._results: Dict[str, JobResult] = {}
|
||||
self._results_lock = threading.RLock()
|
||||
self._initialized = True
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def store_result(
|
||||
self, job_id: str, status: int, result: Optional[Dict[str, Any]], feedback: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""存储任务结果"""
|
||||
with self._results_lock:
|
||||
self._results[job_id] = JobResult(
|
||||
job_id=job_id,
|
||||
status=status,
|
||||
result=result or {},
|
||||
feedback=feedback or {},
|
||||
timestamp=time.time(),
|
||||
)
|
||||
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||
|
||||
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
||||
"""获取并删除任务结果"""
|
||||
with self._results_lock:
|
||||
result = self._results.pop(job_id, None)
|
||||
if result:
|
||||
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||
return result
|
||||
|
||||
def get_result(self, job_id: str) -> Optional[JobResult]:
|
||||
"""仅获取任务结果(不删除)"""
|
||||
with self._results_lock:
|
||||
return self._results.get(job_id)
|
||||
|
||||
def cleanup_old_results(self, max_age_seconds: float = 3600):
|
||||
"""清理过期的结果"""
|
||||
current_time = time.time()
|
||||
with self._results_lock:
|
||||
expired_jobs = [
|
||||
job_id for job_id, result in self._results.items() if current_time - result.timestamp > max_age_seconds
|
||||
]
|
||||
for job_id in expired_jobs:
|
||||
del self._results[job_id]
|
||||
logger.debug(f"[JobResultStore] Cleaned up expired result for job {job_id[:8]}")
|
||||
|
||||
|
||||
# 全局结果存储实例
|
||||
job_result_store = JobResultStore()
|
||||
|
||||
|
||||
def store_job_result(
|
||||
job_id: str, status: str, result: Optional[Dict[str, Any]], feedback: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""存储任务结果(供外部调用)
|
||||
|
||||
Args:
|
||||
job_id: 任务ID
|
||||
status: 状态字符串 ("success", "failed", "cancelled")
|
||||
result: 结果数据
|
||||
feedback: 反馈数据
|
||||
"""
|
||||
# 转换状态字符串为整数
|
||||
status_map = {
|
||||
"success": 4, # SUCCEEDED
|
||||
"failed": 6, # ABORTED
|
||||
"cancelled": 5, # CANCELED
|
||||
"running": 2, # EXECUTING
|
||||
}
|
||||
status_int = status_map.get(status, 0)
|
||||
|
||||
# 只存储最终状态
|
||||
if status_int in (4, 5, 6):
|
||||
job_result_store.store_result(job_id, status_int, result, feedback)
|
||||
|
||||
|
||||
def get_resources() -> Tuple[bool, Any]:
|
||||
"""获取资源配置
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Any]: (是否成功, 资源配置或错误信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, host_node.resources_config
|
||||
|
||||
|
||||
def devices() -> Tuple[bool, Any]:
|
||||
"""获取设备配置
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Any]: (是否成功, 设备配置或错误信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, host_node.devices_config
|
||||
|
||||
|
||||
def job_info(job_id: str, remove_after_read: bool = True) -> JobData:
|
||||
"""获取任务信息
|
||||
|
||||
Args:
|
||||
job_id: 任务ID
|
||||
remove_after_read: 是否在读取后删除结果(默认True)
|
||||
|
||||
Returns:
|
||||
JobData: 任务数据
|
||||
"""
|
||||
# 首先检查结果存储中是否有已完成的结果
|
||||
if remove_after_read:
|
||||
stored_result = job_result_store.get_and_remove(job_id)
|
||||
else:
|
||||
stored_result = job_result_store.get_result(job_id)
|
||||
|
||||
if stored_result:
|
||||
# 有存储的结果,直接返回
|
||||
return JobData(
|
||||
jobId=job_id,
|
||||
status=stored_result.status,
|
||||
result=stored_result.result,
|
||||
)
|
||||
|
||||
# 没有存储的结果,从 HostNode 获取当前状态
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return JobData(jobId=job_id, status=0)
|
||||
|
||||
get_goal_status = host_node.get_goal_status(job_id)
|
||||
return JobData(jobId=job_id, status=get_goal_status)
|
||||
|
||||
|
||||
def check_device_action_busy(device_id: str, action_name: str) -> Tuple[bool, Optional[str]]:
|
||||
"""检查设备动作是否正在执行(被占用)
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
action_name: 动作名称
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (是否繁忙, 当前执行的job_id或None)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, None
|
||||
|
||||
device_action_key = f"/devices/{device_id}/{action_name}"
|
||||
|
||||
# 检查 _device_action_status 中是否有正在执行的任务
|
||||
if device_action_key in host_node._device_action_status:
|
||||
status = host_node._device_action_status[device_action_key]
|
||||
if status.job_ids:
|
||||
# 返回第一个正在执行的job_id
|
||||
current_job_id = next(iter(status.job_ids.keys()), None)
|
||||
return True, current_job_id
|
||||
|
||||
return False, None
|
||||
|
||||
|
||||
def _get_action_type(device_id: str, action_name: str) -> Optional[str]:
|
||||
"""从注册表自动获取动作类型
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
action_name: 动作名称
|
||||
|
||||
Returns:
|
||||
动作类型字符串,未找到返回None
|
||||
"""
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
|
||||
# 方法1: 从运行时注册设备获取
|
||||
if device_id in registered_devices:
|
||||
device_info = registered_devices[device_id]
|
||||
base_node = device_info.get("base_node_instance")
|
||||
if base_node and hasattr(base_node, "_action_value_mappings"):
|
||||
action_mappings = base_node._action_value_mappings
|
||||
# 尝试直接匹配或 auto- 前缀匹配
|
||||
for key in [action_name, f"auto-{action_name}"]:
|
||||
if key in action_mappings:
|
||||
action_type = action_mappings[key].get("type")
|
||||
if action_type:
|
||||
# 转换为字符串格式
|
||||
if hasattr(action_type, "__module__") and hasattr(action_type, "__name__"):
|
||||
return f"{action_type.__module__}.{action_type.__name__}"
|
||||
return str(action_type)
|
||||
|
||||
# 方法2: 从lab_registry获取
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node and lab_registry:
|
||||
devices_config = host_node.devices_config
|
||||
device_class = None
|
||||
|
||||
for tree in devices_config.trees:
|
||||
node = tree.root_node
|
||||
if node.res_content.id == device_id:
|
||||
device_class = node.res_content.klass
|
||||
break
|
||||
|
||||
if device_class and device_class in lab_registry.device_type_registry:
|
||||
device_type_info = lab_registry.device_type_registry[device_class]
|
||||
class_info = device_type_info.get("class", {})
|
||||
action_mappings = class_info.get("action_value_mappings", {})
|
||||
|
||||
for key in [action_name, f"auto-{action_name}"]:
|
||||
if key in action_mappings:
|
||||
action_type = action_mappings[key].get("type")
|
||||
if action_type:
|
||||
if hasattr(action_type, "__module__") and hasattr(action_type, "__name__"):
|
||||
return f"{action_type.__module__}.{action_type.__name__}"
|
||||
return str(action_type)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Controller] Failed to get action type for {device_id}/{action_name}: {str(e)}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def job_add(req: JobAddReq) -> JobData:
|
||||
"""添加任务(检查设备是否繁忙,繁忙则返回失败)
|
||||
|
||||
Args:
|
||||
req: 任务添加请求
|
||||
|
||||
Returns:
|
||||
JobData: 任务数据(包含状态)
|
||||
"""
|
||||
# 服务端自动生成 job_id 和 task_id
|
||||
job_id = str(uuid.uuid4())
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# 服务端自动生成 server_info
|
||||
server_info = {"send_timestamp": time.time()}
|
||||
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
logger.error(f"[Controller] Host node not initialized for job: {job_id[:8]}")
|
||||
return JobData(jobId=job_id, status=6) # 6 = ABORTED
|
||||
|
||||
# 解析动作信息
|
||||
action_name = req.data.get("action", req.action) if req.data else req.action
|
||||
action_args = req.data.get("action_kwargs") or req.data.get("action_args") if req.data else req.action_args
|
||||
|
||||
if action_args is None:
|
||||
action_args = req.action_args or {}
|
||||
elif isinstance(action_args, dict) and "command" in action_args:
|
||||
action_args = action_args["command"]
|
||||
|
||||
# 自动获取 action_type
|
||||
action_type = _get_action_type(req.device_id, action_name)
|
||||
if action_type is None:
|
||||
logger.error(f"[Controller] Action type not found for {req.device_id}/{action_name}")
|
||||
return JobData(jobId=job_id, status=6) # ABORTED
|
||||
|
||||
# 检查设备动作是否繁忙
|
||||
is_busy, current_job_id = check_device_action_busy(req.device_id, action_name)
|
||||
|
||||
if is_busy:
|
||||
logger.warning(
|
||||
f"[Controller] Device action busy: {req.device_id}/{action_name}, "
|
||||
f"current job: {current_job_id[:8] if current_job_id else 'unknown'}"
|
||||
)
|
||||
# 返回失败状态,status=6 表示 ABORTED
|
||||
return JobData(jobId=job_id, status=6)
|
||||
|
||||
# 设备空闲,提交任务执行
|
||||
try:
|
||||
from unilabos.app.ws_client import QueueItem
|
||||
|
||||
device_action_key = f"/devices/{req.device_id}/{action_name}"
|
||||
queue_item = QueueItem(
|
||||
task_type="job_call_back_status",
|
||||
device_id=req.device_id,
|
||||
action_name=action_name,
|
||||
task_id=task_id,
|
||||
job_id=job_id,
|
||||
device_action_key=device_action_key,
|
||||
)
|
||||
|
||||
host_node.send_goal(
|
||||
queue_item,
|
||||
action_type=action_type,
|
||||
action_kwargs=action_args,
|
||||
server_info=server_info,
|
||||
)
|
||||
|
||||
logger.info(f"[Controller] Job submitted: {job_id[:8]} -> {req.device_id}/{action_name}")
|
||||
# 返回已接受状态,status=1 表示 ACCEPTED
|
||||
return JobData(jobId=job_id, status=1)
|
||||
|
||||
except ValueError as e:
|
||||
# ActionClient not found 等错误
|
||||
logger.error(f"[Controller] Action not available: {str(e)}")
|
||||
return JobData(jobId=job_id, status=6) # ABORTED
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error submitting job: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return JobData(jobId=job_id, status=6) # ABORTED
|
||||
|
||||
|
||||
def get_online_devices() -> Tuple[bool, Dict[str, Any]]:
|
||||
"""获取在线设备列表
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict]: (是否成功, 在线设备信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, {"error": "Host node not initialized"}
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
|
||||
online_devices = {}
|
||||
for device_key in host_node._online_devices:
|
||||
# device_key 格式: "namespace/device_id"
|
||||
parts = device_key.split("/")
|
||||
if len(parts) >= 2:
|
||||
device_id = parts[-1]
|
||||
else:
|
||||
device_id = device_key
|
||||
|
||||
# 获取设备详细信息
|
||||
device_info = registered_devices.get(device_id, {})
|
||||
machine_name = host_node.device_machine_names.get(device_id, "未知")
|
||||
|
||||
online_devices[device_id] = {
|
||||
"device_key": device_key,
|
||||
"namespace": host_node.devices_names.get(device_id, ""),
|
||||
"machine_name": machine_name,
|
||||
"uuid": device_info.get("uuid", "") if device_info else "",
|
||||
"node_name": device_info.get("node_name", "") if device_info else "",
|
||||
}
|
||||
|
||||
return True, {
|
||||
"online_devices": online_devices,
|
||||
"total_count": len(online_devices),
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error getting online devices: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False, {"error": str(e)}
|
||||
|
||||
|
||||
def get_device_actions(device_id: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""获取设备可用的动作列表
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict]: (是否成功, 动作列表信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, {"error": "Host node not initialized"}
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
from unilabos.app.web.utils.action_utils import get_action_info
|
||||
|
||||
# 检查设备是否已注册
|
||||
if device_id not in registered_devices:
|
||||
return False, {"error": f"Device not found: {device_id}"}
|
||||
|
||||
device_info = registered_devices[device_id]
|
||||
actions = device_info.get("actions", {})
|
||||
|
||||
actions_list = {}
|
||||
for action_name, action_server in actions.items():
|
||||
try:
|
||||
action_info = get_action_info(action_server, action_name)
|
||||
# 检查动作是否繁忙
|
||||
is_busy, current_job = check_device_action_busy(device_id, action_name)
|
||||
actions_list[action_name] = {
|
||||
**action_info,
|
||||
"is_busy": is_busy,
|
||||
"current_job_id": current_job[:8] if current_job else None,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"[Controller] Error getting action info for {action_name}: {str(e)}")
|
||||
actions_list[action_name] = {
|
||||
"type_name": "unknown",
|
||||
"action_path": f"/devices/{device_id}/{action_name}",
|
||||
"is_busy": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
return True, {
|
||||
"device_id": device_id,
|
||||
"actions": actions_list,
|
||||
"action_count": len(actions_list),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error getting device actions: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False, {"error": str(e)}
|
||||
|
||||
|
||||
def get_action_schema(device_id: str, action_name: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""获取动作的Schema详情
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
action_name: 动作名称
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict]: (是否成功, Schema信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, {"error": "Host node not initialized"}
|
||||
|
||||
try:
|
||||
from unilabos.registry.registry import lab_registry
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
|
||||
result = {
|
||||
"device_id": device_id,
|
||||
"action_name": action_name,
|
||||
"schema": None,
|
||||
"goal_default": None,
|
||||
"action_type": None,
|
||||
"is_busy": False,
|
||||
}
|
||||
|
||||
# 检查动作是否繁忙
|
||||
is_busy, current_job = check_device_action_busy(device_id, action_name)
|
||||
result["is_busy"] = is_busy
|
||||
result["current_job_id"] = current_job[:8] if current_job else None
|
||||
|
||||
# 方法1: 从 registered_devices 获取运行时信息
|
||||
if device_id in registered_devices:
|
||||
device_info = registered_devices[device_id]
|
||||
base_node = device_info.get("base_node_instance")
|
||||
|
||||
if base_node and hasattr(base_node, "_action_value_mappings"):
|
||||
action_mappings = base_node._action_value_mappings
|
||||
if action_name in action_mappings:
|
||||
mapping = action_mappings[action_name]
|
||||
result["schema"] = mapping.get("schema")
|
||||
result["goal_default"] = mapping.get("goal_default")
|
||||
result["action_type"] = str(mapping.get("type", ""))
|
||||
|
||||
# 方法2: 从 lab_registry 获取注册表信息(如果运行时没有)
|
||||
if result["schema"] is None and lab_registry:
|
||||
# 尝试查找设备类型
|
||||
devices_config = host_node.devices_config
|
||||
device_class = None
|
||||
|
||||
# 从配置中获取设备类型
|
||||
for tree in devices_config.trees:
|
||||
node = tree.root_node
|
||||
if node.res_content.id == device_id:
|
||||
device_class = node.res_content.klass
|
||||
break
|
||||
|
||||
if device_class and device_class in lab_registry.device_type_registry:
|
||||
device_type_info = lab_registry.device_type_registry[device_class]
|
||||
class_info = device_type_info.get("class", {})
|
||||
action_mappings = class_info.get("action_value_mappings", {})
|
||||
|
||||
# 尝试直接匹配或 auto- 前缀匹配
|
||||
for key in [action_name, f"auto-{action_name}"]:
|
||||
if key in action_mappings:
|
||||
mapping = action_mappings[key]
|
||||
result["schema"] = mapping.get("schema")
|
||||
result["goal_default"] = mapping.get("goal_default")
|
||||
result["action_type"] = str(mapping.get("type", ""))
|
||||
result["handles"] = mapping.get("handles", {})
|
||||
result["placeholder_keys"] = mapping.get("placeholder_keys", {})
|
||||
break
|
||||
|
||||
if result["schema"] is None:
|
||||
return False, {"error": f"Action schema not found: {device_id}/{action_name}"}
|
||||
|
||||
return True, result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error getting action schema: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False, {"error": str(e)}
|
||||
|
||||
|
||||
def get_all_available_actions() -> Tuple[bool, Dict[str, Any]]:
|
||||
"""获取所有设备的可用动作
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict]: (是否成功, 所有设备的动作信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, {"error": "Host node not initialized"}
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
from unilabos.app.web.utils.action_utils import get_action_info
|
||||
|
||||
all_actions = {}
|
||||
total_action_count = 0
|
||||
|
||||
for device_id, device_info in registered_devices.items():
|
||||
actions = device_info.get("actions", {})
|
||||
device_actions = {}
|
||||
|
||||
for action_name, action_server in actions.items():
|
||||
try:
|
||||
action_info = get_action_info(action_server, action_name)
|
||||
is_busy, current_job = check_device_action_busy(device_id, action_name)
|
||||
device_actions[action_name] = {
|
||||
"type_name": action_info.get("type_name", ""),
|
||||
"action_path": action_info.get("action_path", ""),
|
||||
"is_busy": is_busy,
|
||||
"current_job_id": current_job[:8] if current_job else None,
|
||||
}
|
||||
total_action_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"[Controller] Error processing action {device_id}/{action_name}: {str(e)}")
|
||||
|
||||
if device_actions:
|
||||
all_actions[device_id] = {
|
||||
"actions": device_actions,
|
||||
"action_count": len(device_actions),
|
||||
"machine_name": host_node.device_machine_names.get(device_id, "未知"),
|
||||
}
|
||||
|
||||
return True, {
|
||||
"devices": all_actions,
|
||||
"device_count": len(all_actions),
|
||||
"total_action_count": total_action_count,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error getting all available actions: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False, {"error": str(e)}
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
1411
unilabos/app/web/templates/registry_editor.html
Normal file
1316
unilabos/app/ws_client.py
Normal 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,
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"干燥完成,停止加热"
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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("跳过搅拌器停止", "⏭️"))
|
||||
|
||||
@@ -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类型
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}]
|
||||
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||