Ready for open source (#47)

* Create app/main API

* create example device

* create ROS backend and example device SDK Wrapper

* Add ROS host and host starting from app.py

* Add gripper device and mock implementation

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

* add ActionServer debug example

* [bugfix] complete mock gripper example

* ROS Backend Host for Device action calling and Resource management

* add conda/mamba ENV file

* add host_node communication with app/main.py

* add action message value mappings and converters

* Update ilabos.yaml

* Update issue templates

* example devices.json and resources.json

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

* Fix Device wrapper to use async property and actions

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

* add both sync/async GRBL controller SDK

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

* feature: add job

* fix:node start

* feature:add get job status

* fix:get device

* clean

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

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

* add RunzeSyringePump real device with ROS2 example

* Prototype machine with 1 pump and 1 CNC

* add ROS2ProtocolNode and related functions

* add ilabos_msgs (to use PumpTransfer action)

* add example device connection graph

* refactor protocol_node code into separate file

* add ROS2SerialNode

* add SerialCommand srv in ilabos_msgs

* add pump_protocol example, and fix bugs

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

* use SendCmd instead of SingleJointPosition for valve control

* initialize device connection graph when server starts

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

* add rclpyx and protocol example for async-native workflow

* use async in ROS2ProtocolNode, and host initialization

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

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

* fix pump_protocol: push to 0.0 μL

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

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

* add ilabos-msgs conda recipe

* Update ilabos.yaml

* fix recipe and env yaml

* Add sphinx docs

* add aichemeco

* add bioyong

* add bioyong

* Support XDL devices & protocols (#20)

* [Feature] support multiple protocols in a single ProtocolNode

* add Junjie's code

* Support "Clean" protocol

* Update Grignard_flow_batchreact_single_pumpvalve.json

* test_grignard_add

* add stir device node and example

* Update device_node.py

add print_publish flag to control the node's log output

* NH4Cl_add

* add "HeaterStirrer" device and "HeatChill" action

* add wait time after each pump action for equilibration

* fix stir

* add Separate protocol

* Refactor Separator device and Stir action

* add rotavap_node

* fix stir

* add chiller node

* Move rinsings into PumpTransfer

* Fix SeparateProtocol under refactored Separator device and Stir action

* Supports automatically add new protocol action_types

* fix PumpTransfer protocol because of rinsing

* Add Rotavap and Chiller devices

* fix SeparateProtocol

* add EvaporateProtocol

* add rotavap devices config

* fix HeaterStirrer and SeparatorController IO

* Fix automatically add new protocol action_types

* Add HeaterStirrer and SeparatorController device config

* fix pump protocols

* Fix Evaporate action

* Update evaporate_protocol.py

* add temp_sensor node and add function remap

* update docs

---------

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

* fix aichemeco

* [Bugfix] fix Windows conda packaging

* add file upload api

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

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

* Create aichemeco_simple.py

* fix

* update

* add aichemeco file

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

* add mq

* fix

* clean

* add class

* fix excel

* update bioyong

* add api

* fix

---------

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

* motor & grasp

* Add Grasp motor support and enhance EleGripper class

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

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

* fix read data lenth

* Update Grasp.py

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

* Add bridges in HostNode for device_status publishing

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

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

* fix UUID converting between ROS and MQ

* lint api model.py

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

* add controller config & wrapper

* add controller setup at app.main

* control loop example

* fix com port

* add agv , ur_arm and raman

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

* update http upload api

* generate uuid when init device

* example resource json

* fix

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

* fix full-content json and related reading code

* fix

* add json_schema when initialize resources

* fix

* update schema

* refactor heaterstirrer.dalong

* fix

* fix refactor heaterstirrer.dalong

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

* Update ilabos/ros/host.py

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

---------

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

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

* add distributed launching option "--without_host"

* fix

---------

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

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

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

* add Resource srv and message_converter

* move graphio to ilabos/resources

* refactor resources type conversion

* add resource clients in device_node

* add mock resources service

* pass Gripper1 resource test

* update http resource services

* add AGV compile function

* add AGV transfer protocol

* update full mock_gripper edit_id example

* update full mock_gripper edit_id example

* get and update resource also in protocol_node

* mock resource update in AichemecoHiwo

* Create HT_hiwo.json

* add children in resources

* bugfixes

* fix rpc

* add Revvity winprep

---------

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

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

* add resource_add request to host for slave mode

* add AGV

* fix protocol resources

* optimize host callbacks

* bugfixes

* add revvity registry

---------

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

* Refactor Driver Files Structure (#33)

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

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

* nivo ready version

* Add HPLC driver and example script

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

* 修复start的错误定位

* hplc tested ok

* relative path to build msgs

* template_driver & jiageng devices

* fetch correct status type and action type

* fix mtype fetch

* gpc bus integration

* ilab build

* remove chs

* recipe rename

* modbus update 1

* json available

* hplc & modbus rewrite

* Update AgilentHPLC.py

hplc datafile reader

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

* modbus分设备

* gpc

* gpc 2

* fix address

* default register node

* fix MainScreenGPC

* add Resource srv and message_converter

* move graphio to ilabos/resources

* refactor resources type conversion

* add resource clients in device_node

* add mock resources service

* pass Gripper1 resource test

* update http resource services

* add AGV compile function

* add AGV transfer protocol

* update recipe.yaml

* update full mock_gripper edit_id example

* update full mock_gripper edit_id example

* get and update resource also in protocol_node

* mock resource update in AichemecoHiwo

* feat: add other jiageng PLC device code

* ilabos compile

* correct format

* correct recipe format

* correct setup.py format

* remove unnecessary files

* remove unnecessary files

* Create HT_hiwo.json

* add children in resources

* hplc support sample_id

* correct hplc sample_id

* correct hplc sample_id

* hplc upload

* fix type hint

* oss upload tested ver

* recipe yaml fix for linux

* update installation yaml

* refactor: moved all driver files according to its feat

* merge main to dev

---------

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

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

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

* read chemputer graphml

* read graphml in app/main

* add devices in ros/devices

* add schema for devices

* read registry directory and initialize when entry from main

* Delete devices.py

* Update add_protocol.md

* delete unecessary files

* feat: 2278 devices registry yaml (#36)

* read chemputer graphml

* read graphml in app/main

* add devices in ros/devices

* add schema for devices

* read registry directory and initialize when entry from main

* Delete devices.py

* add: NMR LH and RU device control

* fix: modify jiageng devices registry

---------

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

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

* add resource type conversion to PLR

* add resource registry and test

* add docs

* fix registry

* add solenoid_valve_mock, its registry and test

* fix registry for directly using examples

* add EvacuateAndRefillProtocol and testcases

* allow function sequence call in ACTION

* add read & write & extra_info for hardware_interface

* Update device_node.py

* add solenoid valve

* add doc developer guide yaml

* fixes for starting IK station

* add graphml grouping parser

* fix graphml grouping parser

* add communication edge parser

* fix io solenoid valve

* Update .gitignore

* Update plates.yaml

---------

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

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

* add Uni-Lab docs

* change doc name

* Dev (#41)

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

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

* nivo ready version

* Add HPLC driver and example script

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

* 修复start的错误定位

* hplc tested ok

* relative path to build msgs

* template_driver & jiageng devices

* fetch correct status type and action type

* fix mtype fetch

* gpc bus integration

* ilab build

* remove chs

* recipe rename

* modbus update 1

* json available

* hplc & modbus rewrite

* Update AgilentHPLC.py

hplc datafile reader

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

* modbus分设备

* gpc

* gpc 2

* fix address

* default register node

* fix MainScreenGPC

* add Resource srv and message_converter

* move graphio to ilabos/resources

* refactor resources type conversion

* add resource clients in device_node

* add mock resources service

* pass Gripper1 resource test

* update http resource services

* add AGV compile function

* add AGV transfer protocol

* update recipe.yaml

* update full mock_gripper edit_id example

* update full mock_gripper edit_id example

* get and update resource also in protocol_node

* mock resource update in AichemecoHiwo

* feat: add other jiageng PLC device code

* ilabos compile

* correct format

* correct recipe format

* correct setup.py format

* remove unnecessary files

* remove unnecessary files

* Create HT_hiwo.json

* add children in resources

* hplc support sample_id

* correct hplc sample_id

* correct hplc sample_id

* hplc upload

* fix type hint

* oss upload tested ver

* recipe yaml fix for linux

* update installation yaml

* refactor: moved all driver files according to its feat

* merge main to dev

* add HPLC registry and json

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

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

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

* add resource type conversion to PLR

* add resource registry and test

* feat: 更新oss上传

* fix device id

* add docs

* fix registry

* add solenoid_valve_mock, its registry and test

* fix registry for directly using examples

* add EvacuateAndRefillProtocol and testcases

* allow function sequence call in ACTION

* add read & write & extra_info for hardware_interface

* Update device_node.py

* add solenoid valve

* add doc developer guide yaml

* use robostack-staging

* rclpy version test

* lower rclpy

* ensure 0.6* env

* fixes for starting IK station

* add graphml grouping parser

* fix graphml grouping parser

* add communication edge parser

* fix io solenoid valve

* Update .gitignore

* Update plates.yaml

* Feature/device node later init (#40)

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

* 支持plr序列化/init创建

* 统一命名

* import mgr
logger optimize
banner print

* 日志OK

* fix unicorn frame

* banner print

* correct import format

* file path changes

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

* converter update

* web page update

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

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

* web功能实现结束

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

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

* 修复类型提示

* websocket 动态显示状态

* add workflow & book theme for docs

* add workflow & book theme for docs

* fix workflow build

* fix workflow build

* 理清启动关系

* stm32 example

* mac . name

* device_instance device_cls

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

* plr test

* 移动is_host_mode
新增slave_no_host

* 确保config优先修改生效

* fix graph io

* 支持带参数传入

* 支持物料解析

* 支持物料解析

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

* protocol node new

* protocol node runnable

* protocol node runnable

---------

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

* Dev (#45)

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

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

* nivo ready version

* Add HPLC driver and example script

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

* 修复start的错误定位

* hplc tested ok

* relative path to build msgs

* template_driver & jiageng devices

* fetch correct status type and action type

* fix mtype fetch

* gpc bus integration

* ilab build

* remove chs

* recipe rename

* modbus update 1

* json available

* hplc & modbus rewrite

* Update AgilentHPLC.py

hplc datafile reader

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

* modbus分设备

* gpc

* gpc 2

* fix address

* default register node

* fix MainScreenGPC

* add Resource srv and message_converter

* move graphio to ilabos/resources

* refactor resources type conversion

* add resource clients in device_node

* add mock resources service

* pass Gripper1 resource test

* update http resource services

* add AGV compile function

* add AGV transfer protocol

* update recipe.yaml

* update full mock_gripper edit_id example

* update full mock_gripper edit_id example

* get and update resource also in protocol_node

* mock resource update in AichemecoHiwo

* feat: add other jiageng PLC device code

* ilabos compile

* correct format

* correct recipe format

* correct setup.py format

* remove unnecessary files

* remove unnecessary files

* Create HT_hiwo.json

* add children in resources

* hplc support sample_id

* correct hplc sample_id

* correct hplc sample_id

* hplc upload

* fix type hint

* oss upload tested ver

* recipe yaml fix for linux

* update installation yaml

* refactor: moved all driver files according to its feat

* merge main to dev

* add HPLC registry and json

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

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

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

* add resource type conversion to PLR

* add resource registry and test

* feat: 更新oss上传

* fix device id

* add docs

* fix registry

* add solenoid_valve_mock, its registry and test

* fix registry for directly using examples

* add EvacuateAndRefillProtocol and testcases

* allow function sequence call in ACTION

* add read & write & extra_info for hardware_interface

* Update device_node.py

* add solenoid valve

* add doc developer guide yaml

* use robostack-staging

* rclpy version test

* lower rclpy

* ensure 0.6* env

* fixes for starting IK station

* add graphml grouping parser

* fix graphml grouping parser

* add communication edge parser

* fix io solenoid valve

* Update .gitignore

* Update plates.yaml

* Feature/device node later init (#40)

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

* 支持plr序列化/init创建

* 统一命名

* import mgr
logger optimize
banner print

* 日志OK

* fix unicorn frame

* banner print

* correct import format

* file path changes

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

* converter update

* web page update

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

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

* web功能实现结束

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

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

* 修复类型提示

* websocket 动态显示状态

* add workflow & book theme for docs

* add workflow & book theme for docs

* fix workflow build

* fix workflow build

* 理清启动关系

* stm32 example

* mac . name

* device_instance device_cls

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

* plr test

* 移动is_host_mode
新增slave_no_host

* 确保config优先修改生效

* fix graph io

* 支持带参数传入

* 支持物料解析

* 支持物料解析

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

* protocol node new

* protocol node runnable

* protocol node runnable

* Feature/device node later init (#42)

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

* 支持plr序列化/init创建

* 统一命名

* import mgr
logger optimize
banner print

* 日志OK

* fix unicorn frame

* banner print

* correct import format

* file path changes

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

* converter update

* web page update

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

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

* web功能实现结束

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

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

* 修复类型提示

* websocket 动态显示状态

* add workflow & book theme for docs

* add workflow & book theme for docs

* fix workflow build

* fix workflow build

* 理清启动关系

* stm32 example

* mac . name

* device_instance device_cls

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

* plr test

* 移动is_host_mode
新增slave_no_host

* 确保config优先修改生效

* fix graph io

* 支持带参数传入

* 支持物料解析

* 支持物料解析

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

* protocol node new

* protocol node runnable

* protocol node runnable

* action

* plr suc

* plr suc!!

* plr suc!!

* plr suc!!

* plr msgs

* Feature/device node later init (#43)

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

* 支持plr序列化/init创建

* 统一命名

* import mgr
logger optimize
banner print

* 日志OK

* fix unicorn frame

* banner print

* correct import format

* file path changes

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

* converter update

* web page update

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

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

* web功能实现结束

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

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

* 修复类型提示

* websocket 动态显示状态

* add workflow & book theme for docs

* add workflow & book theme for docs

* fix workflow build

* fix workflow build

* 理清启动关系

* stm32 example

* mac . name

* device_instance device_cls

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

* plr test

* 移动is_host_mode
新增slave_no_host

* 确保config优先修改生效

* fix graph io

* 支持带参数传入

* 支持物料解析

* 支持物料解析

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

* protocol node new

* protocol node runnable

* protocol node runnable

* action

* plr suc

* plr suc!!

* plr suc!!

* plr suc!!

* plr msgs

* plr

* action

* plr reg fix

* Feature/device node later init (#44)

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

* 支持plr序列化/init创建

* 统一命名

* import mgr
logger optimize
banner print

* 日志OK

* fix unicorn frame

* banner print

* correct import format

* file path changes

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

* converter update

* web page update

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

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

* web功能实现结束

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

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

* 修复类型提示

* websocket 动态显示状态

* add workflow & book theme for docs

* add workflow & book theme for docs

* fix workflow build

* fix workflow build

* 理清启动关系

* stm32 example

* mac . name

* device_instance device_cls

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

* plr test

* 移动is_host_mode
新增slave_no_host

* 确保config优先修改生效

* fix graph io

* 支持带参数传入

* 支持物料解析

* 支持物料解析

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

* protocol node new

* protocol node runnable

* protocol node runnable

* action

* plr suc

* plr suc!!

* plr suc!!

* plr suc!!

* plr msgs

* plr

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

* new device test

* test resource add

* test resource add

* test resource add

* test resource add

* local env setup

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

* resource tracker

* fix bug from qhh

* fix bug from qhh

* fix bug from qhh

* fix bug from qhh

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

* driver_params allow empty

* allow other init param

* fix driver param and enhance type hint

* refactor MQConfig to use double quotes for string literals

* fix wrong function calling

* fix wrong function calling

* fix log for mac

* fix networkx compatibility

* add mqtt loggers

* add action to jsonschema converter

* random client id

* type converter & registry

* correct conversion

* fix action publish only from discovered devices

* add "Bio" tag for action doc generation

* 改进module提示

* Fix doc

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

* add docs

* 更新plr test案例

* Update intro.md

* 更新有机案例

* skip

---------

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

---------

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

* Canonicalize before Open Source (#46)

* big big refactor try01

* refactor 02

---------

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

18
unilabos/web/__init__.py Normal file
View File

@@ -0,0 +1,18 @@
"""
Web UI 模块
提供了UniLab系统的Web界面功能
"""
from unilabos.web.pages import setup_web_pages
from unilabos.web.server import setup_server, start_server
from unilabos.web.client import http_client
from unilabos.web.api import setup_api_routes
__all__ = [
"setup_web_pages", # 设置Web页面
"setup_server", # 设置服务器
"start_server", # 启动服务器
"http_client", # HTTP客户端
"setup_api_routes", # 设置API路由
]

197
unilabos/web/api.py Normal file
View File

@@ -0,0 +1,197 @@
"""
API模块
提供API路由和处理函数
"""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
import asyncio
from unilabos.app.controler import devices, job_add, job_info
from unilabos.app.model import (
Resp,
RespCode,
JobStatusResp,
JobAddResp,
JobAddReq,
JobStepFinishReq,
JobPreintakeFinishReq,
JobFinishReq,
)
from unilabos.web.utils.host_utils import get_host_node_info
# 创建API路由器
api = APIRouter()
admin = APIRouter()
# 存储所有活动的WebSocket连接
active_connections: set[WebSocket] = set()
async def broadcast_device_status():
"""广播设备状态到所有连接的客户端"""
while True:
try:
# 获取最新的设备状态
host_info = get_host_node_info()
if host_info["available"]:
# 准备要发送的数据
status_data = {
"type": "device_status",
"data": {
"device_status": host_info["device_status"],
"device_status_timestamps": host_info["device_status_timestamps"],
},
}
# 发送到所有连接的客户端
for connection in active_connections:
try:
await connection.send_json(status_data)
except Exception as e:
print(f"Error sending to client: {e}")
active_connections.remove(connection)
await asyncio.sleep(1) # 每秒更新一次
except Exception as e:
print(f"Error in broadcast: {e}")
await asyncio.sleep(1)
@api.websocket("/ws/device_status")
async def websocket_device_status(websocket: WebSocket):
"""WebSocket端点用于实时获取设备状态"""
await websocket.accept()
active_connections.add(websocket)
try:
while True:
# 保持连接活跃
await websocket.receive_text()
except WebSocketDisconnect:
active_connections.remove(websocket)
except Exception as e:
print(f"WebSocket error: {e}")
active_connections.remove(websocket)
@api.get("/resources", summary="Resource list", response_model=Resp)
def get_resources():
"""获取资源列表"""
isok, data = devices()
if not isok:
return Resp(code=RespCode.ErrorHostNotInit, message=str(data))
return Resp(data=dict(data))
@api.get("/repository", summary="Raw Material list", response_model=Resp)
def get_raw_material():
"""获取原材料列表"""
return Resp(data={})
@api.post("/repository", summary="Raw Material set", response_model=Resp)
def post_raw_material():
"""设置原材料"""
return Resp(data={})
@api.get("/devices", summary="Device list", response_model=Resp)
def get_devices():
"""获取设备列表"""
isok, data = devices()
if not isok:
return Resp(code=RespCode.ErrorHostNotInit, message=str(data))
return Resp(data=dict(data))
@api.get("/devices/{id}/info", summary="Device info", response_model=Resp)
def device_info(id: str):
"""获取设备信息"""
return Resp(data={})
@api.get("/job/{id}/status", summary="Job status", response_model=JobStatusResp)
def job_status(id: str):
"""获取任务状态"""
data = job_info(id)
return JobStatusResp(data=data)
@api.post("/job/add", summary="Create job", response_model=JobAddResp)
def post_job_add(req: JobAddReq):
"""创建任务"""
device_id = req.device_id
if not req.data:
return Resp(code=RespCode.ErrorInvalidReq, message="Invalid request data")
req.device_id = device_id
data = job_add(req)
return JobAddResp(data=data)
@api.post("/job/step_finish", summary="步骤完成推送", response_model=Resp)
def callback_step_finish(req: JobStepFinishReq):
"""任务步骤完成回调"""
print(req)
return Resp(data={})
@api.post("/job/preintake_finish", summary="通量完成推送", response_model=Resp)
def callback_preintake_finish(req: JobPreintakeFinishReq):
"""通量完成回调"""
print(req)
return Resp(data={})
@api.post("/job/finish", summary="完成推送", response_model=Resp)
def callback_order_finish(req: JobFinishReq):
"""任务完成回调"""
print(req)
return Resp(data={})
@admin.get("/device_models", summary="Device model list", response_model=Resp)
def admin_device_models():
"""获取设备模型列表"""
return Resp(data={})
@admin.post("/device_model/add", summary="Add Device model", response_model=Resp)
def admin_device_model_add():
"""添加设备模型"""
return Resp(data={})
@admin.delete("/device_model/{id}", summary="Delete device model", response_model=Resp)
def admin_device_model_del(id: str):
"""删除设备模型"""
return Resp(data={})
@admin.get("/devices", summary="Device list", response_model=Resp)
def admin_devices():
"""获取设备列表(管理员)"""
return Resp(data={})
@admin.post("/devices/add", summary="Add Device", response_model=Resp)
def admin_device_add():
"""添加设备"""
return Resp(data={})
@admin.delete("/devices/{id}", summary="Delete device", response_model=Resp)
def admin_device_del(id: str):
"""删除设备"""
return Resp(data={})
def setup_api_routes(app):
"""设置API路由"""
app.include_router(admin, prefix="/admin/v1", tags=["admin"])
app.include_router(api, prefix="/api/v1", tags=["api"])
# 启动广播任务
@app.on_event("startup")
async def startup_event():
asyncio.create_task(broadcast_device_status())

107
unilabos/web/client.py Normal file
View File

@@ -0,0 +1,107 @@
"""
HTTP客户端模块
提供与远程服务器通信的客户端功能只有host需要用
"""
from typing import List, Dict, Any, Optional
import requests
from unilabos.utils.log import info
from unilabos.config.config import MQConfig, HTTPConfig
class HTTPClient:
"""HTTP客户端用于与远程服务器通信"""
def __init__(self, remote_addr: Optional[str] = None, auth: Optional[str] = None) -> None:
"""
初始化HTTP客户端
Args:
remote_addr: 远程服务器地址,如果不提供则从配置中获取
auth: 授权信息
"""
self.remote_addr = remote_addr or HTTPConfig.remote_addr
if auth is not None:
self.auth = auth
else:
self.auth = MQConfig.lab_id
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
def resource_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
"""
添加资源
Args:
resources: 要添加的资源列表
Returns:
Response: API响应对象
"""
response = requests.post(
f"{self.remote_addr}/lab/resource/",
json=resources,
headers={"Authorization": f"lab {self.auth}"},
timeout=5,
)
return response
def resource_get(self, id: str, with_children: bool = False) -> Dict[str, Any]:
"""
获取资源
Args:
id: 资源ID
with_children: 是否包含子资源
Returns:
Dict: 返回的资源数据
"""
response = requests.get(
f"{self.remote_addr}/lab/resource/",
params={"id": id, "with_children": with_children},
headers={"Authorization": f"lab {self.auth}"},
timeout=5,
)
return response.json()
def resource_del(self, id: str) -> requests.Response:
"""
删除资源
Args:
id: 要删除的资源ID
Returns:
Response: API响应对象
"""
response = requests.delete(
f"{self.remote_addr}/lab/resource/batch_delete/",
params={"id": id},
headers={"Authorization": f"lab {self.auth}"},
timeout=5,
)
return response
def resource_update(self, resources: List[Dict[str, Any]]) -> requests.Response:
"""
更新资源
Args:
resources: 要更新的资源列表
Returns:
Response: API响应对象
"""
response = requests.patch(
f"{self.remote_addr}/lab/resource/batch_update/",
json=resources,
headers={"Authorization": f"lab {self.auth}"},
timeout=5,
)
return response
# 创建默认客户端实例
http_client = HTTPClient()

184
unilabos/web/pages.py Normal file
View File

@@ -0,0 +1,184 @@
"""
Web页面模块
提供系统Web界面的页面定义
"""
import json
import os
import sys
from pathlib import Path
from typing import Dict
from fastapi import APIRouter, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse
from jinja2 import Environment, FileSystemLoader
from unilabos.config.config import BasicConfig
from unilabos.registry.registry import lab_registry
from unilabos.app.mq import mqtt_client
from unilabos.ros.msgs.message_converter import msg_converter_manager
from unilabos.utils.log import error
from unilabos.utils.type_check import TypeEncoder
from unilabos.web.utils.device_utils import get_registry_info
from unilabos.web.utils.host_utils import get_host_node_info
from unilabos.web.utils.ros_utils import get_ros_node_info, update_ros_node_info
# 设置Jinja2模板环境
template_dir = Path(__file__).parent / "templates"
env = Environment(loader=FileSystemLoader(template_dir))
def setup_web_pages(router: APIRouter) -> None:
"""
设置Web页面路由
Args:
router: FastAPI路由器实例
"""
# 在web服务启动时尝试初始化ROS节点信息
update_ros_node_info()
@router.get("/", response_class=HTMLResponse, summary="Home Page")
async def home_page() -> str:
"""
首页显示所有可用的API路由
Returns:
HTMLResponse: 渲染后的HTML页面
"""
try:
# 收集所有路由
routes = []
for route in router.routes:
if hasattr(route, "methods") and hasattr(route, "path"):
for method in list(getattr(route, "methods", [])):
path = getattr(route, "path", "")
# 只显示GET方法的路由作为链接
if method == "GET":
name = getattr(route, "name", "") or path
summary = getattr(route, "summary", "") or name
routes.append({"method": method, "path": path, "name": name, "summary": summary})
# 使用模板渲染页面
template = env.get_template("home.html")
html = template.render(routes=routes)
return html
except Exception as e:
error(f"生成主页时出错: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error generating home page: {str(e)}")
@router.get("/status", response_class=HTMLResponse, summary="System Status")
async def status_page() -> str:
"""
状态页面,显示系统状态信息
Returns:
HTMLResponse: 渲染后的HTML页面
"""
try:
# 准备设备数据
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_info = get_registry_info()
# 获取已加载的设备
if lab_registry:
# 设备类型
for device_id, device_info in lab_registry.device_type_registry.items():
msg = {
"id": device_id,
"name": device_info.get("name", "未命名"),
"file_path": device_info.get("file_path", ""),
"class_json": json.dumps(
device_info.get("class", {}), indent=4, ensure_ascii=False, cls=TypeEncoder
),
}
mqtt_client.publish_registry(device_id, device_info)
devices.append(msg)
# 资源类型
for resource_id, resource_info in lab_registry.resource_type_registry.items():
resources.append(
{
"id": resource_id,
"name": resource_info.get("name", "未命名"),
"file_path": resource_info.get("file_path", ""),
}
)
# 获取导入的模块
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]
modules["total_count"] = len(all_classes)
modules["classes"] = all_classes
# 使用模板渲染页面
template = env.get_template("status.html")
html = template.render(
devices=devices,
resources=resources,
modules=modules,
is_host_mode=BasicConfig.is_host_mode,
registry_info=registry_info,
ros_node_info=ros_node_info,
host_node_info=host_node_info,
)
return html
except Exception as e:
error(f"生成状态页面时出错: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error generating status page: {str(e)}")
@router.get("/open-folder", response_class=JSONResponse, summary="Open Local Folder")
async def open_folder(path: str = "") -> Dict[str, str]:
"""
打开本地文件夹
Args:
path: 要打开的文件夹路径
Returns:
JSONResponse: 操作结果
Raises:
HTTPException: 如果路径为空或不存在
"""
if not path:
return {"status": "error", "message": "Path is empty"}
try:
# 规范化路径
norm_path = os.path.normpath(path)
# 如果是文件路径,获取其目录
if os.path.isfile(norm_path):
norm_path = os.path.dirname(norm_path)
# 检查路径是否存在
if not os.path.exists(norm_path):
return {"status": "error", "message": f"Path does not exist: {norm_path}"}
# Windows
if os.name == "nt":
os.startfile(norm_path)
# macOS
elif sys.platform == "darwin":
os.system(f'open "{norm_path}"')
# Linux
else:
os.system(f'xdg-open "{norm_path}"')
return {"status": "success", "message": f"Opened folder: {norm_path}"}
except Exception as e:
error(f"打开文件夹时出错: {str(e)}")
return {"status": "error", "message": f"Failed to open folder: {str(e)}"}

131
unilabos/web/server.py Normal file
View File

@@ -0,0 +1,131 @@
"""
Web服务器模块
提供Web服务器功能网页信息服务 + mqtt代替
"""
import webbrowser
import uvicorn
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import Response
from unilabos.utils.fastapi.log_adapter import setup_fastapi_logging
from unilabos.utils.log import info, error
from unilabos.web.api import setup_api_routes
from unilabos.web.pages import setup_web_pages
# 创建FastAPI应用
app = FastAPI(
title="UniLab API",
description="UniLab API Service",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
)
# 创建页面路由
pages = None
# noinspection PyTypeChecker
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "Accept"],
)
@app.middleware("http")
async def log_requests(request: Request, call_next) -> Response:
"""
记录HTTP请求日志的中间件
Args:
request: 当前HTTP请求对象
call_next: 下一个处理函数
Returns:
Response: HTTP响应对象
"""
# # 打印请求信息
# info(f"[Web] Request: {request.method} {request.url}", stack_level=1)
# debug(f"[Web] Headers: {request.headers}", stack_level=1)
#
# # 使用日志模块记录请求体(如果需要)
# body = await request.body()
# if body:
# debug(f"[Web] Body: {body}", stack_level=1)
# 调用下一个中间件或路由处理函数
response = await call_next(request)
# # 打印响应信息
# info(f"[Web] Response status: {response.status_code}", stack_level=1)
return response
def setup_server() -> FastAPI:
"""
设置服务器
Returns:
FastAPI: 配置好的FastAPI应用实例
"""
global pages
# 创建页面路由
if pages is None:
pages = app.router
# 设置API路由
setup_api_routes(app)
# 设置页面路由
try:
setup_web_pages(pages)
info("[Web] 已加载Web UI模块")
except ImportError as e:
info(f"[Web] 未找到Web页面模块: {str(e)}")
except Exception as e:
error(f"[Web] 加载Web页面模块时出错: {str(e)}")
return app
def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> None:
"""
启动服务器
Args:
host: 服务器主机
port: 服务器端口
open_browser: 是否自动打开浏览器
"""
# 设置服务器
setup_server()
# 配置日志
log_config = setup_fastapi_logging()
# 启动前打开浏览器
if open_browser:
# noinspection HttpUrlsUsage
url = f"http://{host if host != '0.0.0.0' else 'localhost'}:{port}/status"
info(f"[Web] 正在打开浏览器访问: {url}")
try:
webbrowser.open(url)
except Exception as e:
error(f"[Web] 无法打开浏览器: {str(e)}")
# 启动服务器
info(f"[Web] 启动FastAPI服务器: {host}:{port}")
uvicorn.run(app, host=host, port=port, log_config=log_config)
# 当脚本直接运行时启动服务器
if __name__ == "__main__":
start_server()

View File

@@ -0,0 +1,509 @@
/* 基础样式 */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
margin: 0;
padding: 0;
}
/* 系统模式样式 */
.system-mode-banner {
background: #f0f8ff;
padding: 8px 15px;
margin-bottom: 10px;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.mode-indicator {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 10px;
border-radius: 3px;
font-size: 14px;
}
.mode-indicator.host-mode {
background-color: #e6f7ff;
border-left: 4px solid #1890ff;
color: #0050b3;
}
.mode-indicator.slave-mode {
background-color: #fff7e6;
border-left: 4px solid #fa8c16;
color: #873800;
}
.mode-detail {
font-size: 12px;
opacity: 0.8;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background: #2c3e50;
color: white;
padding: 20px;
text-align: center;
margin-bottom: 20px;
border-radius: 5px;
}
header h1 {
margin: 0;
font-size: 24px;
}
.card {
background: white;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
overflow: hidden;
padding: 20px;
}
.card h2 {
color: #2c3e50;
margin-top: 0;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
/* 表格样式 */
.responsive-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.responsive-table th,
.responsive-table td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.responsive-table th {
background-color: #f8f9fa;
font-weight: 600;
}
.collapsible-row {
cursor: pointer;
}
.collapsible-row:hover {
background-color: #f1f8ff;
}
.collapsible-row.active {
background-color: #e6f7ff;
border-bottom: none;
}
.detail-row td {
background-color: #f9f9f9;
padding: 0;
}
.detail-row .content-full {
padding: 15px;
}
.toggle-indicator,
.toggle-sub-indicator {
float: right;
color: #999;
transition: transform 0.2s;
}
.collapsible-row.active .toggle-indicator {
transform: rotate(180deg);
}
/* 主题样式 */
.topics-container {
max-height: 300px;
overflow-y: auto;
border: 1px solid #eee;
border-radius: 4px;
background: #fcfcfc;
}
.topics-list {
display: flex;
flex-wrap: wrap;
padding: 10px;
}
.topic-item {
background: #f0f7ff;
border-radius: 4px;
margin: 5px;
padding: 5px 10px;
font-size: 13px;
display: flex;
align-items: center;
border: 1px solid #d6e8ff;
}
.topic-name {
margin-right: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 280px;
}
/* 计数徽章 */
.count-badge {
background: #e6f7ff;
color: #1890ff;
border-radius: 10px;
padding: 2px 8px;
font-size: 12px;
font-weight: normal;
margin-left: 5px;
}
/* 主机节点区域 */
.host-section {
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px dashed #eee;
}
.host-section:last-child {
border-bottom: none;
margin-bottom: 0;
}
.host-section h3 {
color: #1890ff;
font-size: 1.1em;
margin-bottom: 10px;
display: flex;
align-items: center;
}
/* 状态徽章 */
.status-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: 500;
}
.status-badge.online {
background-color: #e6ffec;
color: #52c41a;
border: 1px solid #b7eb8f;
}
.status-badge.offline {
background-color: #fff1f0;
color: #f5222d;
border: 1px solid #ffa39e;
}
.status-badge.ready {
background-color: #e6ffec;
color: #52c41a;
border: 1px solid #b7eb8f;
}
.status-badge.not-ready {
background-color: #fff7e6;
color: #fa8c16;
border: 1px solid #ffd591;
}
/* 空状态提示 */
.empty-state {
text-align: center;
padding: 20px;
color: #999;
font-style: italic;
}
/* 内部表格 */
.inner-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
.inner-table th,
.inner-table td {
padding: 8px;
border: 1px solid #eee;
font-size: 0.9em;
}
.inner-table th {
background-color: #f8f9fa;
font-weight: 600;
}
.topic-row,
.action-row {
cursor: pointer;
}
.topic-row:hover,
.action-row:hover {
background-color: #f1f8ff;
}
.cmd-row td {
padding: 0;
}
.cmd-block {
background-color: #f5f5f5;
padding: 10px 15px;
border-radius: 3px;
margin: 5px 0;
}
.cmd-line {
display: flex;
align-items: center;
margin: 10px 0;
}
.cmd-line pre {
flex: 1;
background-color: #f1f1f1;
padding: 8px;
border-radius: 3px;
overflow-x: auto;
font-size: 13px;
margin: 0;
margin-right: 10px;
}
.copy-btn {
background-color: #1890ff;
color: white;
border: none;
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s;
}
.copy-btn:hover {
background-color: #096dd9;
}
.copy-btn.small {
padding: 2px 6px;
font-size: 11px;
}
.copy-btn.copy-success {
background-color: #52c41a;
}
.goal-tip {
font-size: 12px;
color: #888;
margin: 5px 0 0 0;
font-style: italic;
}
/* 文件路径样式 */
.file-path {
position: relative;
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.folder-link {
cursor: pointer;
margin-left: 8px;
color: #1890ff;
}
.folder-link:hover {
color: #096dd9;
}
/* 注册表路径样式 */
.registry-info {
background-color: #f9f9f9;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
font-size: 0.9em;
}
.registry-path {
margin-bottom: 10px;
}
.registry-path:last-child {
margin-bottom: 0;
}
.path-list {
list-style: none;
padding-left: 10px;
margin: 5px 0;
}
.path-list li {
margin-bottom: 5px;
display: flex;
align-items: center;
}
.path {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 导航标签 */
.nav-tabs {
display: flex;
margin-bottom: 20px;
border-bottom: 1px solid #ddd;
overflow-x: auto;
white-space: nowrap;
padding-bottom: 1px;
}
.nav-tab {
padding: 8px 16px;
color: #666;
text-decoration: none;
margin-right: 2px;
background-color: #f8f9fa;
border: 1px solid #ddd;
border-bottom: none;
border-radius: 4px 4px 0 0;
transition: all 0.2s;
}
.nav-tab:hover {
background-color: #e9ecef;
color: #333;
}
.nav-tab:active {
background-color: #fff;
border-bottom: 1px solid white;
margin-bottom: -1px;
color: #1890ff;
}
/* 调试按钮 */
.debug-btn {
background-color: #e8e8e8;
color: #666;
border: none;
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
margin-left: 5px;
transition: background-color 0.2s;
}
.debug-btn:hover {
background-color: #d9d9d9;
}
.debug-info {
margin-top: 10px;
background-color: #fafafa;
border: 1px solid #eee;
padding: 10px;
border-radius: 3px;
font-size: 12px;
overflow: auto;
max-height: 200px;
}
/* 返回顶部按钮 */
#back-to-top {
display: none;
position: fixed;
bottom: 20px;
right: 20px;
background-color: #1890ff;
color: white;
width: 40px;
height: 40px;
border-radius: 50%;
text-align: center;
line-height: 40px;
font-size: 20px;
cursor: pointer;
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 1000;
transition: all 0.3s;
}
#back-to-top:hover {
background-color: #096dd9;
transform: translateY(-3px);
}
/* 响应式设计 */
@media (max-width: 768px) {
.responsive-table {
display: block;
overflow-x: auto;
}
.card {
padding: 15px;
}
.cmd-line {
flex-direction: column;
align-items: stretch;
}
.cmd-line pre {
margin-right: 0;
margin-bottom: 10px;
}
.topics-list {
flex-direction: column;
}
.topic-item {
width: 100%;
box-sizing: border-box;
}
.nav-tabs {
overflow-x: auto;
flex-wrap: nowrap;
}
.nav-tab {
flex: 0 0 auto;
}
}

View File

@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}UniLab{% endblock %}</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; }
h1, h2, h3 { color: #333; }
.card { background: #f5f5f5; border-radius: 5px; padding: 15px; margin-bottom: 15px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
th { background-color: #f2f2f2; }
tr:hover { background-color: #f5f5f5; }
.folder-link { color: #4CAF50; cursor: pointer; margin-left: 5px; }
a { color: #2196F3; text-decoration: none; }
a:hover { text-decoration: underline; }
.home-link { display: block; background: #2196F3; color: white; padding: 10px 15px;
border-radius: 4px; margin: 20px 0; text-align: center; }
.status-link { display: block; background: #4CAF50; color: white; padding: 10px 15px;
border-radius: 4px; margin: 20px 0; text-align: center; }
.endpoint { margin-bottom: 10px; padding: 10px; background: #fff; border-radius: 3px;
border-left: 4px solid #2196F3; }
.method { font-weight: bold; color: #2196F3; }
/* 折叠面板样式 */
.collapsible { background-color: #f8f8f8; cursor: pointer; padding: 10px;
border: none; text-align: left; outline: none; margin-bottom: 1px;
font-weight: bold; color: #2196F3; width: 100%; border-radius: 4px; }
.active, .collapsible:hover { background-color: #e6f2ff; }
.content { padding: 0 10px; max-height: 0; overflow: hidden;
transition: max-height 0.2s ease-out; background-color: white; }
.content pre { margin: 10px 0; white-space: pre-wrap; max-height: 400px; overflow: auto; }
/* 整行折叠样式 */
.collapsible-row { transition: background-color 0.3s; }
.collapsible-row:hover { background-color: #e6f2ff; }
.collapsible-row.active { background-color: #e6f2ff; }
.detail-row { background-color: #f8f8f8; }
.content-full { padding: 15px; background-color: white; border-radius: 4px; }
.content-full pre { margin: 0; white-space: pre-wrap; max-height: 400px; overflow: auto; }
.toggle-info { color: #2196F3; font-weight: bold; }
/* Registry信息样式 */
.registry-info {
background-color: #e9f7fe;
padding: 10px 15px;
margin: 10px 0 20px 0;
border-radius: 5px;
border-left: 4px solid #2196F3;
}
.registry-path {
margin: 10px 0;
font-size: 0.9em;
}
.path {
font-family: monospace;
color: #555;
}
.path-list {
margin: 5px 0;
padding-left: 20px;
list-style-type: none;
}
.path-list li {
margin-bottom: 3px;
padding: 3px 0;
}
/* 导航标签样式 */
.nav-tabs {
display: flex;
margin-bottom: 20px;
border-bottom: 1px solid #ddd;
padding-bottom: 10px;
}
.nav-tab {
margin-right: 15px;
padding: 8px 16px;
background-color: #f8f9fa;
border-radius: 4px;
text-decoration: none;
color: #555;
transition: all 0.2s;
}
.nav-tab:hover {
background-color: #e9ecef;
color: #333;
}
/* 在线状态样式 */
.online-status {
background-color: #d4edda;
color: #155724;
padding: 3px 8px;
border-radius: 3px;
font-size: 0.85em;
}
.offline-status {
background-color: #f8d7da;
color: #721c24;
padding: 3px 8px;
border-radius: 3px;
font-size: 0.85em;
}
/* 内部表格样式 */
.inner-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 15px;
font-size: 0.9em;
}
.inner-table th, .inner-table td {
padding: 8px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.inner-table th {
background-color: #f8f9fa;
font-weight: 600;
}
/* 详情列表样式 */
.detail-list {
margin: 5px 0 15px 0;
padding-left: 20px;
}
.detail-list li {
margin-bottom: 8px;
}
/* 返回顶部按钮 */
#back-to-top {
display: none;
position: fixed;
bottom: 20px;
right: 30px;
z-index: 99;
border: none;
outline: none;
background-color: #2196F3;
color: white;
cursor: pointer;
padding: 15px;
border-radius: 50%;
font-size: 18px;
width: 50px;
height: 50px;
line-height: 20px;
text-align: center;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
transition: all 0.3s;
}
#back-to-top:hover {
background-color: #0b7dda;
}
.file-path { font-family: monospace; font-size: 0.9em; color: #666; }
.classes-count { color: #999; font-size: 0.9em; margin-left: 5px; }
</style>
{% block scripts %}{% endblock %}
</head>
<body>
<h1>{% block header %}UniLab{% endblock %}</h1>
{% block nav %}
<a href="/unilabos/webtic" class="home-link">Home</a>
{% endblock %}
{% block top_info %}{% endblock %}
{% block content %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,22 @@
{% 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 %}
</div>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

View File

@@ -0,0 +1,360 @@
"""
Action 工具函数模块
提供处理 ROS Action 相关的辅助函数
"""
import traceback
from typing import Dict, Any, Type, TypedDict, Optional
from rclpy.action import ActionClient, ActionServer
from rosidl_parser.definition import UnboundedSequence, NamespacedType, BasicType
from unilabos.ros.msgs.message_converter import msg_converter_manager
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
from unilabos.utils import logger
class ActionInfoType(TypedDict):
type_name: str
type_name_convert: str
action_path: str
goal_info: str
def get_action_info(
v: ActionClient | ActionServer, name: Optional[str] = None, full_name: Optional[str] = None
) -> ActionInfoType:
# noinspection PyProtectedMember
n: BaseROS2DeviceNode = v._node
if full_name is None:
assert name is not None
full_name = n.namespace + "/" + name
# noinspection PyProtectedMember
return {
"type_name": v._action_type.__module__ + "." + v._action_type.__name__,
"type_name_convert": (v._action_type.__module__ + "." + v._action_type.__name__).replace(".", "/"),
"action_path": full_name,
"goal_info": get_yaml_from_goal_type(v._action_type.Goal),
}
def get_ros_msg_instance_as_dict(ros_msg_instance):
full_dict = {}
lower_dir = {i.lower(): i for i in dir(ros_msg_instance)}
for k in dir(ros_msg_instance):
if k == "SLOT_TYPES" or k.startswith("_") or k.endswith("__DEFAULT") or k in ["get_fields_and_field_types"]:
continue
v = getattr(ros_msg_instance, k)
if f"{k.lower()}__default" in lower_dir:
v_default = getattr(ros_msg_instance, lower_dir[f"{k.lower()}__default"])
v = v_default
if isinstance(v, (str, int, float, list, dict)):
full_dict[k] = v
else:
full_dict[k] = get_ros_msg_instance_as_dict(v)
return full_dict
def get_yaml_from_goal_type(goal_type) -> str:
"""从Goal类型对象中生成默认YAML格式字符串
Args:
goal_type: Goal类型对象
Returns:
str: 默认Goal参数的YAML格式字符串
"""
if not goal_type:
return "{}"
goal_dict = {}
slot_type = None
try:
for ind, slot_info in enumerate(goal_type._fields_and_field_types.items()):
slot_name, slot_type = slot_info
type_info = goal_type.SLOT_TYPES[ind]
default_value = "unknown"
if isinstance(type_info, UnboundedSequence):
inner_type = type_info.value_type
if isinstance(inner_type, NamespacedType):
cls_name = ".".join(inner_type.namespaces) + ":" + inner_type.name
type_class = msg_converter_manager.get_class(cls_name)
default_value = [get_ros_msg_instance_as_dict(type_class())]
elif isinstance(inner_type, BasicType):
default_value = [get_default_value_for_ros_type(inner_type.typename)]
else:
default_value = "unknown"
elif isinstance(type_info, NamespacedType):
cls_name = ".".join(type_info.namespaces) + ":" + type_info.name
type_class = msg_converter_manager.get_class(cls_name)
if type_class is None:
print("type_class", type_class, cls_name)
default_value = get_ros_msg_instance_as_dict(type_class())
elif isinstance(type_info, BasicType):
default_value = get_default_value_for_ros_type(type_info.typename)
else:
type_class = msg_converter_manager.search_class(slot_type, search_lower=True)
if type_class is not None:
default_value = type_class().data
else:
default_value = "unknown"
goal_dict[slot_name] = default_value
except Exception as e:
logger.error(f"获取Goal字段 {slot_type} 信息时出错: {e}")
logger.error(traceback.format_exc())
# 将字典转换为YAML格式字符串
yaml_str = "{"
# 每个字段转换为YAML格式
yaml_parts = []
for key, value in goal_dict.items():
if isinstance(value, str):
yaml_parts.append(f"{key}: '{value}'")
elif isinstance(value, bool):
yaml_parts.append(f"{key}: {str(value).lower()}")
elif isinstance(value, (int, float)):
yaml_parts.append(f"{key}: {value}")
elif isinstance(value, dict) and not value:
yaml_parts.append(f"{key}: {{}}")
else:
yaml_parts.append(f"{key}: {value}")
yaml_str += ", ".join(yaml_parts) + "}"
return yaml_str
"""旧版本函数"""
def get_default_value_for_ros_type(type_hint_or_str: Any) -> Any:
"""生成基于ROS类型提示或字符串的默认值
根据ROS2类型定义生成适当的默认值。支持基本类型、数组类型和嵌套消息类型。
Args:
type_hint_or_str: ROS2类型提示或类型名称字符串
Returns:
Any: 对应类型的默认值
"""
# 处理None或无效输入
if type_hint_or_str is None:
return None
# 基本类型映射
type_str = str(type_hint_or_str).lower() # 使用字符串表示
# 处理常见基本类型
if "int" in type_str:
return 0
if "float" in type_str or "double" in type_str:
return 0.0
if "bool" in type_str:
return False
if "string" in type_str:
return ""
if "byte" in type_str or "char" in type_str:
return 0 # 用整数表示
if "time" == type_str or "duration" == type_str:
return {"sec": 0, "nanosec": 0}
# 处理数组 - 返回空列表
if "sequence" in type_str or "vector" in type_str or "[]" in type_str:
return []
# 处理嵌套消息类型 - 返回空字典占位符
if "." in str(type_hint_or_str):
# 尝试用消息转换管理器查找类型并生成默认值
try:
type_name = str(type_hint_or_str).strip().split("[")[0] # 移除数组部分
# 尝试查找类型
if msg_converter_manager:
type_class = msg_converter_manager.search_class(type_name)
if type_class:
# 递归生成默认值字典
return generate_example_dict_from_ros_class(type_class)
except Exception as e:
print(f"查找类型默认值时出错: {type_hint_or_str}, {e}")
# 如果找不到或出错,返回空字典
return {}
# 特殊类型的默认值
if "pose" in type_str or "position" in type_str:
return {"x": 0.0, "y": 0.0, "z": 0.0}
if "orientation" in type_str or "quaternion" in type_str:
return {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0}
if "header" in type_str:
return {"frame_id": "", "stamp": {"sec": 0, "nanosec": 0}}
return None # 未知类型
def generate_example_dict_from_ros_class(ros_class: Any) -> Dict[str, Any]:
"""检查ROS消息/服务/动作类并生成带有默认值的字典
分析ROS2消息类定义提取其字段结构并为每个字段生成合适的默认值。
Args:
ros_class: ROS2消息/服务/动作类或其实例
Returns:
Dict[str, Any]: 包含消息字段及默认值的字典
"""
example_dict = {}
# 检查是否已经是字典
if isinstance(ros_class, dict):
return ros_class
# 处理无效输入
if ros_class is None:
return {}
# 获取字段信息
fields = {}
try:
if hasattr(ros_class, "_fields_and_field_types"):
fields = ros_class._fields_and_field_types
elif hasattr(ros_class, "__slots__") and hasattr(ros_class, "__annotations__"):
for slot in getattr(ros_class, "__slots__", []):
field_name = slot # 假设slot名称与字段名称匹配
if field_name in getattr(ros_class, "__annotations__", {}):
fields[field_name] = ros_class.__annotations__[field_name]
else:
fields[field_name] = "unknown" # 如果缺少类型提示则使用默认值
except Exception as e:
print(f"获取ROS类字段信息时出错: {e}")
return {}
# 为每个字段生成默认值
for field_name, field_type in fields.items():
example_dict[field_name] = get_default_value_for_ros_type(field_type)
return example_dict
def extract_action_structures(action_type: Type) -> Dict[str, Any]:
"""从Action类型对象中提取Goal/Result/Feedback结构"""
result = {"goal": {}, "result": {}, "feedback": {}}
try:
# 检查action_type是否为合法对象
if hasattr(action_type, "Goal"):
# 获取Goal类及其字段
goal_class = getattr(action_type, "Goal", None)
if goal_class:
result["goal"] = generate_example_dict_from_ros_class(goal_class)
# 获取Result类及其字段
result_class = getattr(action_type, "Result", None)
if result_class:
result["result"] = generate_example_dict_from_ros_class(result_class)
# 获取Feedback类及其字段
feedback_class = getattr(action_type, "Feedback", None)
if feedback_class:
result["feedback"] = generate_example_dict_from_ros_class(feedback_class)
except Exception as e:
print(f"提取Action结构时出错: {type(action_type)}")
print(traceback.format_exc())
return result
def process_device_actions(action_config: Dict[str, Any], action_type: Type, action_name: str) -> Dict[str, Any]:
"""处理设备动作,生成命令示例和结构信息
Args:
action_config: 动作配置信息包含topic等内容
action_type: 动作类型,可以是类型对象或字符串
action_name: 动作名称
Returns:
Dict[str, Any]: 包含命令示例和结构信息的字典
"""
# 检查action_type是否为None或非法值
if action_type is None:
# 返回基本结构,确保前端不会报错
return {
"topic": action_config.get("topic", "UNKNOWN_TOPIC"),
"type_str": "UNKNOWN_TYPE",
"goal": "{}",
"full_command": f"ros2 action send_goal {action_config.get('topic', 'UNKNOWN_TOPIC')} UNKNOWN_TYPE '{{}}'",
"goal_dict": {},
"result_dict": {},
"feedback_dict": {},
}
# 提取类型路径字符串,从<class 'package.action._action_name.ActionName'>格式转换为package/action/ActionName
type_str = str(action_type)[8:-2] # 去除<class ' ... '>
parts = type_str.split(".")
# 构造ROS2类型字符串
if len(parts) >= 3 and "action" in parts:
action_idx = parts.index("action")
if action_idx >= 0 and action_idx < len(parts) - 1:
package_name = parts[0]
action_class_name = parts[-1]
ros2_type_str = f"{package_name}/action/{action_class_name}"
else:
ros2_type_str = type_str.replace(".", "/")
else:
ros2_type_str = type_str.replace(".", "/")
# 提取动作结构
action_structures = extract_action_structures(action_type)
# 获取goal部分并转换为YAML格式
goal_dict = action_structures["goal"]
goal_yaml = dict_to_yaml_str(goal_dict)
# 获取topic
topic = action_config.get("topic", "UNKNOWN_TOPIC")
return {
"topic": topic,
"type_str": ros2_type_str,
"goal": goal_yaml,
"full_command": f"ros2 action send_goal {topic} {ros2_type_str} '{goal_yaml}'",
"goal_dict": goal_dict,
"result_dict": action_structures["result"],
"feedback_dict": action_structures["feedback"],
}
def dict_to_yaml_str(d: Dict) -> str:
"""将字典转换为YAML字符串单行格式
Args:
d: 要转换的字典
Returns:
str: YAML格式的字符串
"""
if not d:
return "{}"
parts = []
def format_value(v):
if isinstance(v, str):
return f"'{v}'"
elif isinstance(v, bool):
return str(v).lower()
elif isinstance(v, (int, float)) or v is None:
return str(v)
elif isinstance(v, list):
items = [format_value(item) for item in v]
return f"[{', '.join(items)}]"
elif isinstance(v, dict):
return dict_to_yaml_str(v)
return "null"
for key, value in d.items():
parts.append(f"{key}: {format_value(value)}")
return "{" + ", ".join(parts) + "}"

View File

@@ -0,0 +1,58 @@
"""
设备工具函数模块
提供处理设备配置的辅助函数
"""
import json
from typing import Dict, Any
# 这里不能循环导入
# 在函数内部导入process_device_actions
# from unilabos.web.utils.action_utils import process_device_actions
def get_registry_info() -> Dict[str, Any]:
"""获取Registry相关路径信息
Returns:
包含Registry路径信息的字典
"""
from unilabos.registry.registry import lab_registry
from pathlib import Path
registry_info = {}
if lab_registry:
# 获取所有registry路径
if hasattr(lab_registry, "registry_paths") and lab_registry.registry_paths:
# 保存所有注册表路径
registry_info["paths"] = [str(path).replace("\\", "/") for path in lab_registry.registry_paths]
# 获取设备和资源的相关路径
for reg_path in lab_registry.registry_paths:
base_path = Path(reg_path)
# 检查设备目录
devices_path = base_path / "devices"
if devices_path.exists():
if "devices_paths" not in registry_info:
registry_info["devices_paths"] = []
registry_info["devices_paths"].append(str(devices_path).replace("\\", "/"))
# 检查设备通信目录
device_comms_path = base_path / "device_comms"
if device_comms_path.exists():
if "device_comms_paths" not in registry_info:
registry_info["device_comms_paths"] = []
registry_info["device_comms_paths"].append(str(device_comms_path).replace("\\", "/"))
# 检查资源目录
resources_path = base_path / "resources"
if resources_path.exists():
if "resources_paths" not in registry_info:
registry_info["resources_paths"] = []
registry_info["resources_paths"].append(str(resources_path).replace("\\", "/"))
return registry_info

View File

@@ -0,0 +1,68 @@
"""
主机节点工具模块
提供与主机节点相关的工具函数
"""
import time
from typing import Dict, Any
from unilabos.config.config import BasicConfig
from unilabos.ros.nodes.presets.host_node import HostNode
from unilabos.web.utils.action_utils import get_action_info
def get_host_node_info() -> Dict[str, Any]:
"""
获取主机节点信息
尝试获取HostNode实例并提取其设备、主题和动作客户端信息
Returns:
Dict: 包含主机节点信息的字典
"""
host_info = {"available": False, "devices": {}, "subscribed_topics": [], "action_clients": {}}
if not BasicConfig.is_host_mode:
return host_info
# 尝试获取HostNode实例设置超时为0秒
host_node = HostNode.get_instance(0)
if not host_node:
return host_info
host_info["available"] = True
host_info["devices"] = {
device_id: {
"namespace": namespace,
"is_online": f"{namespace}/{device_id}" in host_node._online_devices,
"key": f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}",
}
for device_id, namespace in host_node.devices_names.items()
}
# 获取已订阅的主题
host_info["subscribed_topics"] = sorted(list(host_node._subscribed_topics))
# 获取动作客户端信息
for action_id, client in host_node._action_clients.items():
host_info["action_clients"] = {
action_id: get_action_info(client, full_name=action_id)
}
# 获取设备状态
host_info["device_status"] = host_node.device_status
# 添加设备状态更新时间戳
current_time = time.time()
host_info["device_status_timestamps"] = {}
for device_id, properties in host_node.device_status_timestamps.items():
host_info["device_status_timestamps"][device_id] = {}
for prop_name, timestamp in properties.items():
if timestamp > 0: # 只处理有效的时间戳
host_info["device_status_timestamps"][device_id][prop_name] = {
"timestamp": timestamp,
"elapsed": round(current_time - timestamp, 2), # 计算经过的时间(秒)
}
else:
host_info["device_status_timestamps"][device_id][prop_name] = {
"timestamp": 0,
"elapsed": -1, # 表示未曾更新过
}
return host_info

View File

@@ -0,0 +1,68 @@
"""
ROS 工具函数模块
提供处理 ROS 节点信息的辅助函数
"""
import traceback
from typing import Dict, Any
from unilabos.web.utils.action_utils import get_action_info
# 存储 ROS 节点信息的全局变量
ros_node_info = {"online_devices": {}, "device_topics": {}, "device_actions": {}}
def get_ros_node_info() -> Dict[str, Any]:
"""获取 ROS 节点信息,包括设备节点、发布的状态和动作
Returns:
包含 ROS 节点信息的字典
"""
global ros_node_info
# 触发更新以获取最新信息
update_ros_node_info()
return ros_node_info
def update_ros_node_info() -> Dict[str, Any]:
"""更新 ROS 节点信息,使用全局设备注册表
Returns:
更新后的 ROS 节点信息字典
"""
global ros_node_info
result = {"registered_devices": {}, "device_topics": {}, "device_actions": {}}
try:
from unilabos.ros.nodes.base_device_node import registered_devices
for device_id, device_info in registered_devices.items():
# 设备基本信息
result["registered_devices"][device_id] = {
"node_name": device_info["node_name"],
"namespace": device_info["namespace"],
"uuid": device_info["uuid"],
}
# 设备话题(状态)信息
result["device_topics"][device_id] = {
k: {
"type_name": v.msg_type.__module__ + "." + v.msg_type.__name__,
"timer_period": v.timer_period,
"topic_path": device_info["base_node_instance"].namespace + "/" + v.name,
}
for k, v in device_info["status_publishers"].items()
}
# 设备动作信息
result["device_actions"][device_id] = {
k: get_action_info(v, k)
for k, v in device_info["actions"].items()
}
# 更新全局变量
ros_node_info = result
except Exception as e:
print(f"更新ROS节点信息出错: {e}")
traceback.print_exc()
return result