支持local_config启动 添加注册表description字段 (#13)

Closes #11

* Update README and MQTTClient for installation instructions and code improvements

* feat: 支持local_config启动
add: 增加对crt path的说明,为传入config.py的相对路径
move: web component

* add: registry description

---------

Co-authored-by: Harvey Que <Q-Query@outlook.com>
This commit is contained in:
Xuwznln
2025-04-20 18:24:45 +08:00
committed by GitHub
parent 22a02bdb06
commit 35ada068cc
39 changed files with 114 additions and 34 deletions

View File

@@ -69,14 +69,17 @@ def main():
args_dict = vars(args)
# 加载配置文件 - 这里保持最先加载配置的逻辑
if args_dict.get("config"):
config_path = args_dict["config"]
config_path = args_dict.get("config")
if config_path:
if not os.path.exists(config_path):
print_status(f"配置文件 {config_path} 不存在", "error")
elif not config_path.endswith(".py"):
print_status(f"配置文件 {config_path} 不是Python文件必须以.py结尾", "error")
else:
load_config(config_path)
else:
print_status(f"启动 UniLab-OS时配置文件参数未正确传入 --config '{config_path}' 尝试本地配置...", "warning")
load_config(config_path)
# 设置BasicConfig参数
BasicConfig.is_host_mode = not args_dict.get("without_host", False)
@@ -92,8 +95,8 @@ def main():
from unilabos.app.mq import mqtt_client
from unilabos.registry.registry import build_registry
from unilabos.app.backend import start_backend
from unilabos.web import http_client
from unilabos.web import start_server
from unilabos.app.web import http_client
from unilabos.app.web import start_server
# 显示启动横幅
print_unilab_banner(args_dict)

View File

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

197
unilabos/app/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.app.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/app/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/app/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.app.web.utils.device_utils import get_registry_info
from unilabos.app.web.utils.host_utils import get_host_node_info
from unilabos.app.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/app/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.app.web.api import setup_api_routes
from unilabos.app.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.app.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.app.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