diff --git a/example_devices.py b/example_devices.py
new file mode 100644
index 00000000..d5b26b2c
--- /dev/null
+++ b/example_devices.py
@@ -0,0 +1,588 @@
+"""
+示例设备类文件,用于测试注册表编辑器
+"""
+
+import asyncio
+from typing import Dict, Any, Optional, List
+
+
+class SmartPumpController:
+ """
+ 智能泵控制器
+
+ 支持多种泵送模式,具有高精度流量控制和自动校准功能。
+ 适用于实验室自动化系统中的液体处理任务。
+ """
+
+ def __init__(self, device_id: str = "smart_pump_01", port: str = "/dev/ttyUSB0"):
+ """
+ 初始化智能泵控制器
+
+ Args:
+ device_id: 设备唯一标识符
+ port: 通信端口
+ """
+ self.device_id = device_id
+ self.port = port
+ self.is_connected = False
+ self.current_flow_rate = 0.0
+ self.total_volume_pumped = 0.0
+ self.calibration_factor = 1.0
+ self.pump_mode = "continuous" # continuous, volume, rate
+
+ def connect_device(self, timeout: int = 10) -> bool:
+ """
+ 连接到泵设备
+
+ Args:
+ timeout: 连接超时时间(秒)
+
+ Returns:
+ bool: 连接是否成功
+ """
+ # 模拟连接过程
+ self.is_connected = True
+ return True
+
+ def disconnect_device(self) -> bool:
+ """
+ 断开设备连接
+
+ Returns:
+ bool: 断开连接是否成功
+ """
+ self.is_connected = False
+ self.current_flow_rate = 0.0
+ return True
+
+ def set_flow_rate(self, flow_rate: float, units: str = "ml/min") -> bool:
+ """
+ 设置泵流速
+
+ Args:
+ flow_rate: 流速值
+ units: 流速单位
+
+ Returns:
+ bool: 设置是否成功
+ """
+ if not self.is_connected:
+ return False
+
+ self.current_flow_rate = flow_rate
+ return True
+
+ async def pump_volume_async(self, volume: float, flow_rate: float) -> Dict[str, Any]:
+ """
+ 异步泵送指定体积的液体
+
+ Args:
+ volume: 目标体积 (mL)
+ flow_rate: 泵送流速 (mL/min)
+
+ Returns:
+ Dict: 包含操作结果的字典
+ """
+ if not self.is_connected:
+ return {"success": False, "error": "设备未连接"}
+
+ # 计算泵送时间
+ pump_time = (volume / flow_rate) * 60 # 转换为秒
+
+ self.current_flow_rate = flow_rate
+ await asyncio.sleep(min(pump_time, 3.0)) # 模拟泵送过程
+
+ self.total_volume_pumped += volume
+ self.current_flow_rate = 0.0
+
+ return {
+ "success": True,
+ "pumped_volume": volume,
+ "actual_time": min(pump_time, 3.0),
+ "total_volume": self.total_volume_pumped,
+ }
+
+ def emergency_stop(self) -> bool:
+ """
+ 紧急停止泵
+
+ Returns:
+ bool: 停止是否成功
+ """
+ self.current_flow_rate = 0.0
+ return True
+
+ def perform_calibration(self, reference_volume: float, measured_volume: float) -> bool:
+ """
+ 执行泵校准
+
+ Args:
+ reference_volume: 参考体积
+ measured_volume: 实际测量体积
+
+ Returns:
+ bool: 校准是否成功
+ """
+ if measured_volume > 0:
+ self.calibration_factor = reference_volume / measured_volume
+ return True
+ return False
+
+ # 状态查询方法
+ def get_connection_status(self) -> str:
+ """获取连接状态"""
+ return "connected" if self.is_connected else "disconnected"
+
+ def get_current_flow_rate(self) -> float:
+ """获取当前流速 (mL/min)"""
+ return self.current_flow_rate
+
+ def get_total_volume(self) -> float:
+ """获取累计泵送体积 (mL)"""
+ return self.total_volume_pumped
+
+ def get_calibration_factor(self) -> float:
+ """获取校准因子"""
+ return self.calibration_factor
+
+ def get_pump_mode(self) -> str:
+ """获取泵送模式"""
+ return self.pump_mode
+
+ def get_device_status(self) -> Dict[str, Any]:
+ """获取设备完整状态信息"""
+ return {
+ "device_id": self.device_id,
+ "connected": self.is_connected,
+ "flow_rate": self.current_flow_rate,
+ "total_volume": self.total_volume_pumped,
+ "calibration_factor": self.calibration_factor,
+ "mode": self.pump_mode,
+ "running": self.current_flow_rate > 0,
+ }
+
+
+class AdvancedTemperatureController:
+ """
+ 高级温度控制器
+
+ 支持PID控制、多点温度监控和程序化温度曲线。
+ 适用于需要精确温度控制的化学反应和材料处理过程。
+ """
+
+ def __init__(self, controller_id: str = "temp_controller_01"):
+ """
+ 初始化温度控制器
+
+ Args:
+ controller_id: 控制器ID
+ """
+ self.controller_id = controller_id
+ self.current_temperature = 25.0
+ self.target_temperature = 25.0
+ self.is_heating = False
+ self.is_cooling = False
+ self.pid_enabled = True
+ self.temperature_history: List[Dict] = []
+
+ def set_target_temperature(self, temperature: float, rate: float = 10.0) -> bool:
+ """
+ 设置目标温度
+
+ Args:
+ temperature: 目标温度 (°C)
+ rate: 升温/降温速率 (°C/min)
+
+ Returns:
+ bool: 设置是否成功
+ """
+ self.target_temperature = temperature
+ return True
+
+ async def heat_to_temperature_async(
+ self, temperature: float, tolerance: float = 0.5, timeout: int = 600
+ ) -> Dict[str, Any]:
+ """
+ 异步加热到指定温度
+
+ Args:
+ temperature: 目标温度 (°C)
+ tolerance: 温度容差 (°C)
+ timeout: 最大等待时间 (秒)
+
+ Returns:
+ Dict: 操作结果
+ """
+ self.target_temperature = temperature
+ start_temp = self.current_temperature
+
+ if temperature > start_temp:
+ self.is_heating = True
+ elif temperature < start_temp:
+ self.is_cooling = True
+
+ # 模拟温度变化过程
+ steps = min(abs(temperature - start_temp) * 2, 20) # 计算步数
+ step_time = min(timeout / steps if steps > 0 else 1, 2.0) # 每步最多2秒
+
+ for step in range(int(steps)):
+ progress = (step + 1) / steps
+ self.current_temperature = start_temp + (temperature - start_temp) * progress
+
+ # 记录温度历史
+ self.temperature_history.append(
+ {
+ "timestamp": asyncio.get_event_loop().time(),
+ "temperature": self.current_temperature,
+ "target": self.target_temperature,
+ }
+ )
+
+ await asyncio.sleep(step_time)
+
+ # 保持历史记录不超过100条
+ if len(self.temperature_history) > 100:
+ self.temperature_history.pop(0)
+
+ # 最终设置为目标温度
+ self.current_temperature = temperature
+ self.is_heating = False
+ self.is_cooling = False
+
+ return {
+ "success": True,
+ "final_temperature": self.current_temperature,
+ "start_temperature": start_temp,
+ "time_taken": steps * step_time,
+ }
+
+ def enable_pid_control(self, kp: float = 1.0, ki: float = 0.1, kd: float = 0.05) -> bool:
+ """
+ 启用PID控制
+
+ Args:
+ kp: 比例增益
+ ki: 积分增益
+ kd: 微分增益
+
+ Returns:
+ bool: 启用是否成功
+ """
+ self.pid_enabled = True
+ return True
+
+ def run_temperature_program(self, program: List[Dict]) -> bool:
+ """
+ 运行温度程序
+
+ Args:
+ program: 温度程序列表,每个元素包含温度和持续时间
+
+ Returns:
+ bool: 程序启动是否成功
+ """
+ # 模拟程序启动
+ return True
+
+ # 状态查询方法
+ def get_current_temperature(self) -> float:
+ """获取当前温度 (°C)"""
+ return round(self.current_temperature, 2)
+
+ def get_target_temperature(self) -> float:
+ """获取目标温度 (°C)"""
+ return self.target_temperature
+
+ def get_heating_status(self) -> bool:
+ """获取加热状态"""
+ return self.is_heating
+
+ def get_cooling_status(self) -> bool:
+ """获取制冷状态"""
+ return self.is_cooling
+
+ def get_pid_status(self) -> bool:
+ """获取PID控制状态"""
+ return self.pid_enabled
+
+ def get_temperature_history(self) -> List[Dict]:
+ """获取温度历史记录"""
+ return self.temperature_history[-10:] # 返回最近10条记录
+
+ def get_controller_status(self) -> Dict[str, Any]:
+ """获取控制器完整状态"""
+ return {
+ "controller_id": self.controller_id,
+ "current_temp": self.current_temperature,
+ "target_temp": self.target_temperature,
+ "is_heating": self.is_heating,
+ "is_cooling": self.is_cooling,
+ "pid_enabled": self.pid_enabled,
+ "history_count": len(self.temperature_history),
+ }
+
+
+class MultiChannelAnalyzer:
+ """
+ 多通道分析仪
+
+ 支持同时监测多个通道的信号,提供实时数据采集和分析功能。
+ 常用于光谱分析、电化学测量等应用场景。
+ """
+
+ def __init__(self, analyzer_id: str = "analyzer_01", channels: int = 8):
+ """
+ 初始化多通道分析仪
+
+ Args:
+ analyzer_id: 分析仪ID
+ channels: 通道数量
+ """
+ self.analyzer_id = analyzer_id
+ self.channel_count = channels
+ self.channel_data = {i: {"value": 0.0, "unit": "V", "enabled": True} for i in range(channels)}
+ self.is_measuring = False
+ self.sample_rate = 1000 # Hz
+
+ def configure_channel(self, channel: int, enabled: bool = True, unit: str = "V") -> bool:
+ """
+ 配置通道
+
+ Args:
+ channel: 通道编号
+ enabled: 是否启用
+ unit: 测量单位
+
+ Returns:
+ bool: 配置是否成功
+ """
+ if 0 <= channel < self.channel_count:
+ self.channel_data[channel]["enabled"] = enabled
+ self.channel_data[channel]["unit"] = unit
+ return True
+ return False
+
+ async def start_measurement_async(self, duration: int = 10) -> Dict[str, Any]:
+ """
+ 开始异步测量
+
+ Args:
+ duration: 测量持续时间(秒)
+
+ Returns:
+ Dict: 测量结果
+ """
+ self.is_measuring = True
+
+ # 模拟数据采集
+ measurements = []
+ for second in range(duration):
+ timestamp = asyncio.get_event_loop().time()
+ frame_data = {}
+
+ for channel in range(self.channel_count):
+ if self.channel_data[channel]["enabled"]:
+ # 模拟传感器数据
+ import random
+
+ value = random.uniform(-5.0, 5.0)
+ frame_data[f"channel_{channel}"] = value
+ self.channel_data[channel]["value"] = value
+
+ measurements.append({"timestamp": timestamp, "data": frame_data})
+
+ await asyncio.sleep(1.0) # 每秒采集一次
+
+ self.is_measuring = False
+
+ return {
+ "success": True,
+ "duration": duration,
+ "samples_count": len(measurements),
+ "measurements": measurements[-5:], # 只返回最后5个样本
+ "channels_active": len([ch for ch in self.channel_data.values() if ch["enabled"]]),
+ }
+
+ def stop_measurement(self) -> bool:
+ """
+ 停止测量
+
+ Returns:
+ bool: 停止是否成功
+ """
+ self.is_measuring = False
+ return True
+
+ def reset_channels(self) -> bool:
+ """
+ 重置所有通道
+
+ Returns:
+ bool: 重置是否成功
+ """
+ for channel in self.channel_data:
+ self.channel_data[channel]["value"] = 0.0
+ return True
+
+ # 状态查询方法
+ def get_measurement_status(self) -> bool:
+ """获取测量状态"""
+ return self.is_measuring
+
+ def get_channel_count(self) -> int:
+ """获取通道数量"""
+ return self.channel_count
+
+ def get_sample_rate(self) -> float:
+ """获取采样率 (Hz)"""
+ return self.sample_rate
+
+ def get_channel_values(self) -> Dict[int, float]:
+ """获取所有通道的当前值"""
+ return {ch: data["value"] for ch, data in self.channel_data.items() if data["enabled"]}
+
+ def get_enabled_channels(self) -> List[int]:
+ """获取已启用的通道列表"""
+ return [ch for ch, data in self.channel_data.items() if data["enabled"]]
+
+ def get_analyzer_status(self) -> Dict[str, Any]:
+ """获取分析仪完整状态"""
+ return {
+ "analyzer_id": self.analyzer_id,
+ "channel_count": self.channel_count,
+ "is_measuring": self.is_measuring,
+ "sample_rate": self.sample_rate,
+ "active_channels": len(self.get_enabled_channels()),
+ "channel_data": self.channel_data,
+ }
+
+
+class AutomatedDispenser:
+ """
+ 自动分配器
+
+ 精确控制固体和液体材料的分配,支持多种分配模式和容器管理。
+ 集成称重功能,确保分配精度和重现性。
+ """
+
+ def __init__(self, dispenser_id: str = "dispenser_01"):
+ """
+ 初始化自动分配器
+
+ Args:
+ dispenser_id: 分配器ID
+ """
+ self.dispenser_id = dispenser_id
+ self.is_ready = True
+ self.current_position = {"x": 0.0, "y": 0.0, "z": 0.0}
+ self.dispensed_total = 0.0
+ self.container_capacity = 1000.0 # mL
+ self.precision_mode = True
+
+ def move_to_position(self, x: float, y: float, z: float) -> bool:
+ """
+ 移动到指定位置
+
+ Args:
+ x: X坐标 (mm)
+ y: Y坐标 (mm)
+ z: Z坐标 (mm)
+
+ Returns:
+ bool: 移动是否成功
+ """
+ self.current_position = {"x": x, "y": y, "z": z}
+ return True
+
+ async def dispense_liquid_async(self, volume: float, container_id: str, viscosity: str = "low") -> Dict[str, Any]:
+ """
+ 异步分配液体
+
+ Args:
+ volume: 分配体积 (mL)
+ container_id: 容器ID
+ viscosity: 液体粘度等级
+
+ Returns:
+ Dict: 分配结果
+ """
+ if not self.is_ready:
+ return {"success": False, "error": "设备未就绪"}
+
+ if volume <= 0:
+ return {"success": False, "error": "体积必须大于0"}
+
+ # 模拟分配过程
+ dispense_time = volume * 0.1 # 每mL需要0.1秒
+ if viscosity == "high":
+ dispense_time *= 2 # 高粘度液体需要更长时间
+
+ await asyncio.sleep(min(dispense_time, 5.0)) # 最多等待5秒
+
+ self.dispensed_total += volume
+
+ return {
+ "success": True,
+ "dispensed_volume": volume,
+ "container_id": container_id,
+ "actual_time": min(dispense_time, 5.0),
+ "total_dispensed": self.dispensed_total,
+ }
+
+ def clean_dispenser(self, wash_volume: float = 5.0) -> bool:
+ """
+ 清洗分配器
+
+ Args:
+ wash_volume: 清洗液体积 (mL)
+
+ Returns:
+ bool: 清洗是否成功
+ """
+ # 模拟清洗过程
+ return True
+
+ def calibrate_volume(self, target_volume: float) -> bool:
+ """
+ 校准分配体积
+
+ Args:
+ target_volume: 校准目标体积 (mL)
+
+ Returns:
+ bool: 校准是否成功
+ """
+ # 模拟校准过程
+ return True
+
+ # 状态查询方法
+ def get_ready_status(self) -> bool:
+ """获取就绪状态"""
+ return self.is_ready
+
+ def get_current_position(self) -> Dict[str, float]:
+ """获取当前位置坐标"""
+ return self.current_position.copy()
+
+ def get_dispensed_total(self) -> float:
+ """获取累计分配体积 (mL)"""
+ return self.dispensed_total
+
+ def get_container_capacity(self) -> float:
+ """获取容器容量 (mL)"""
+ return self.container_capacity
+
+ def get_precision_mode(self) -> bool:
+ """获取精密模式状态"""
+ return self.precision_mode
+
+ def get_dispenser_status(self) -> Dict[str, Any]:
+ """获取分配器完整状态"""
+ return {
+ "dispenser_id": self.dispenser_id,
+ "ready": self.is_ready,
+ "position": self.current_position,
+ "dispensed_total": self.dispensed_total,
+ "capacity": self.container_capacity,
+ "precision_mode": self.precision_mode,
+ }
diff --git a/unilabos/app/web/api.py b/unilabos/app/web/api.py
index eb18b4dc..bf51f4af 100644
--- a/unilabos/app/web/api.py
+++ b/unilabos/app/web/api.py
@@ -26,6 +26,49 @@ admin = APIRouter()
# 存储所有活动的WebSocket连接
active_connections: set[WebSocket] = set()
+# 存储注册表编辑器的WebSocket连接
+registry_editor_connections: set[WebSocket] = set()
+# 存储状态页面的WebSocket连接
+status_page_connections: set[WebSocket] = set()
+
+# 状态跟踪变量,用于差异检测
+_static_data_sent_connections: set[WebSocket] = set()
+_previous_host_node_info: dict = {}
+_previous_local_devices: dict = {}
+
+
+def compute_host_node_diff(current: dict, previous: dict) -> dict:
+ """计算主机节点信息的差异,只返回有变化的部分"""
+ diff = {}
+
+ # 检查可用性变化
+ if current.get("available") != previous.get("available"):
+ diff["available"] = current.get("available")
+
+ # 检查设备列表变化
+ current_devices = current.get("devices", {})
+ previous_devices = previous.get("devices", {})
+ if current_devices != previous_devices:
+ diff["devices"] = current_devices
+
+ # 检查动作客户端变化
+ current_action_clients = current.get("action_clients", {})
+ previous_action_clients = previous.get("action_clients", {})
+ if current_action_clients != previous_action_clients:
+ diff["action_clients"] = current_action_clients
+
+ # 检查订阅主题变化
+ current_topics = current.get("subscribed_topics", [])
+ previous_topics = previous.get("subscribed_topics", [])
+ if current_topics != previous_topics:
+ diff["subscribed_topics"] = current_topics
+
+ # 设备状态始终包含(因为需要实时更新)
+ if "device_status" in current:
+ diff["device_status"] = current["device_status"]
+ diff["device_status_timestamps"] = current.get("device_status_timestamps", {})
+
+ return diff
async def broadcast_device_status():
@@ -56,6 +99,137 @@ async def broadcast_device_status():
await asyncio.sleep(1)
+async def broadcast_status_page_data():
+ """广播状态页面数据到所有连接的客户端(优化版:增量更新)"""
+ global _previous_local_devices, _static_data_sent_connections, _previous_host_node_info
+
+ while True:
+ try:
+ if status_page_connections:
+ from unilabos.app.web.utils.host_utils import get_host_node_info
+ from unilabos.app.web.utils.ros_utils import get_ros_node_info
+ from unilabos.app.web.utils.device_utils import get_registry_info
+ from unilabos.config.config import BasicConfig
+ from unilabos.registry.registry import lab_registry
+ from unilabos.ros.msgs.message_converter import msg_converter_manager
+ from unilabos.utils.type_check import TypeEncoder
+ import json
+
+ # 获取当前数据
+ host_node_info = get_host_node_info()
+ ros_node_info = get_ros_node_info()
+
+ # 检查需要发送静态数据的新连接
+ new_connections = status_page_connections - _static_data_sent_connections
+
+ # 向新连接发送静态数据(Device Types、Resource Types、Converter Modules)
+ if new_connections:
+ devices = []
+ resources = []
+ modules = {"names": [], "classes": [], "displayed_count": 0, "total_count": 0}
+
+ if lab_registry:
+ devices = json.loads(
+ json.dumps(lab_registry.obtain_registry_device_info(), ensure_ascii=False, cls=TypeEncoder)
+ )
+ # 资源类型
+ for resource_id, resource_info in lab_registry.resource_type_registry.items():
+ resources.append(
+ {
+ "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
+
+ # 静态数据
+ registry_info = get_registry_info()
+ static_data = {
+ "type": "static_data_init",
+ "data": {
+ "devices": devices,
+ "resources": resources,
+ "modules": modules,
+ "registry_info": registry_info,
+ "is_host_mode": BasicConfig.is_host_mode,
+ "host_node_info": host_node_info, # 添加主机节点初始信息
+ "ros_node_info": ros_node_info, # 添加本地设备初始信息
+ },
+ }
+
+ # 发送到新连接
+ disconnected_new_connections = set()
+ for connection in new_connections:
+ try:
+ await connection.send_json(static_data)
+ _static_data_sent_connections.add(connection)
+ except Exception as e:
+ print(f"Error sending static data to new client: {e}")
+ disconnected_new_connections.add(connection)
+
+ # 清理断开的新连接
+ for conn in disconnected_new_connections:
+ status_page_connections.discard(conn)
+ _static_data_sent_connections.discard(conn)
+
+ # 检查主机节点信息是否有变更
+ host_node_diff = compute_host_node_diff(host_node_info, _previous_host_node_info)
+ host_changed = bool(host_node_diff)
+
+ # 检查Local Devices是否有变更
+ current_devices = ros_node_info.get("registered_devices", {})
+ devices_changed = current_devices != _previous_local_devices
+
+ # 只有当有真正的变化时才发送更新
+ if host_changed or devices_changed:
+ # 发送增量更新数据
+ update_data = {
+ "type": "incremental_update",
+ "data": {
+ "timestamp": asyncio.get_event_loop().time(),
+ },
+ }
+
+ # 只包含有变化的主机节点信息
+ if host_changed:
+ update_data["data"]["host_node_info"] = host_node_diff
+
+ # 如果Local Devices发生变更,添加到更新数据中
+ if devices_changed:
+ update_data["data"]["ros_node_info"] = ros_node_info
+ _previous_local_devices = current_devices.copy()
+
+ # 更新主机节点状态
+ if host_changed:
+ _previous_host_node_info = host_node_info.copy()
+
+ # 发送增量更新到所有连接
+ disconnected_connections = set()
+ for connection in status_page_connections:
+ try:
+ await connection.send_json(update_data)
+ except Exception as e:
+ print(f"Error sending incremental update to client: {e}")
+ disconnected_connections.add(connection)
+
+ # 清理断开的连接
+ for conn in disconnected_connections:
+ status_page_connections.discard(conn)
+ _static_data_sent_connections.discard(conn)
+
+ await asyncio.sleep(1) # 每秒检查一次更新
+ except Exception as e:
+ print(f"Error in status page broadcast: {e}")
+ await asyncio.sleep(1)
+
+
@api.websocket("/ws/device_status")
async def websocket_device_status(websocket: WebSocket):
"""WebSocket端点,用于实时获取设备状态"""
@@ -72,6 +246,838 @@ async def websocket_device_status(websocket: WebSocket):
active_connections.remove(websocket)
+@api.websocket("/ws/registry_editor")
+async def websocket_registry_editor(websocket: WebSocket):
+ """WebSocket端点,用于注册表编辑器"""
+ await websocket.accept()
+ registry_editor_connections.add(websocket)
+
+ try:
+ while True:
+ # 接收来自客户端的消息
+ message = await websocket.receive_text()
+ import json
+
+ data = json.loads(message)
+
+ if data.get("type") == "import_file":
+ await handle_file_import(websocket, data["data"])
+ elif data.get("type") == "analyze_file":
+ await handle_file_analysis(websocket, data["data"])
+ elif data.get("type") == "analyze_file_content":
+ await handle_file_content_analysis(websocket, data["data"])
+ elif data.get("type") == "import_file_content":
+ await handle_file_content_import(websocket, data["data"])
+
+ except WebSocketDisconnect:
+ registry_editor_connections.remove(websocket)
+ except Exception as e:
+ print(f"Registry Editor WebSocket error: {e}")
+ if websocket in registry_editor_connections:
+ registry_editor_connections.remove(websocket)
+
+
+@api.websocket("/ws/status_page")
+async def websocket_status_page(websocket: WebSocket):
+ """WebSocket端点,用于状态页面实时数据更新"""
+ await websocket.accept()
+ status_page_connections.add(websocket)
+
+ try:
+ while True:
+ # 接收来自客户端的消息(用于保持连接活跃)
+ message = await websocket.receive_text()
+ # 状态页面通常只需要接收数据,不需要发送复杂指令
+
+ except WebSocketDisconnect:
+ status_page_connections.remove(websocket)
+ except Exception as e:
+ print(f"Status Page WebSocket error: {e}")
+ if websocket in status_page_connections:
+ status_page_connections.remove(websocket)
+
+
+async def handle_file_analysis(websocket: WebSocket, request_data: dict):
+ """处理文件分析请求,获取文件中的类列表"""
+ import json
+ import os
+ import sys
+ import inspect
+ import traceback
+ from pathlib import Path
+ from unilabos.config.config import BasicConfig
+
+ file_path = request_data.get("file_path")
+
+ async def send_log(message: str, level: str = "info"):
+ """发送日志消息到客户端"""
+ try:
+ await websocket.send_text(json.dumps({"type": "log", "message": message, "level": level}))
+ except Exception as e:
+ print(f"Failed to send log: {e}")
+
+ async def send_analysis_result(result_data: dict):
+ """发送分析结果到客户端"""
+ try:
+ await websocket.send_text(json.dumps({"type": "file_analysis_result", "data": result_data}))
+ except Exception as e:
+ print(f"Failed to send analysis result: {e}")
+
+ try:
+ # 验证文件路径参数
+ if not file_path:
+ await send_analysis_result({"success": False, "error": "文件路径为空", "file_path": ""})
+ return
+
+ # 获取工作目录并构建完整路径
+ working_dir_str = getattr(BasicConfig, "working_dir", None) or os.getcwd()
+ working_dir = Path(working_dir_str)
+ full_file_path = working_dir / file_path
+
+ # 验证文件路径
+ if not full_file_path.exists():
+ await send_analysis_result(
+ {"success": False, "error": f"文件路径不存在: {file_path}", "file_path": file_path}
+ )
+ return
+
+ await send_log(f"开始分析文件: {file_path}")
+
+ # 验证文件是Python文件
+ if not file_path.endswith(".py"):
+ await send_analysis_result({"success": False, "error": "请选择Python文件 (.py)", "file_path": file_path})
+ return
+
+ full_file_path = full_file_path.absolute()
+ await send_log(f"文件绝对路径: {full_file_path}")
+
+ # 添加文件目录到sys.path
+ file_dir = str(full_file_path.parent)
+ if file_dir not in sys.path:
+ sys.path.insert(0, file_dir)
+ await send_log(f"已添加路径到sys.path: {file_dir}")
+
+ # 确定模块名
+ module_name = full_file_path.stem
+ await send_log(f"使用模块名: {module_name}")
+
+ # 导入模块进行分析
+ try:
+ # 如果模块已经导入,先删除以便重新导入
+ if module_name in sys.modules:
+ del sys.modules[module_name]
+ await send_log(f"已删除旧模块: {module_name}")
+
+ import importlib.util
+
+ spec = importlib.util.spec_from_file_location(module_name, full_file_path)
+ if spec is None or spec.loader is None:
+ await send_analysis_result(
+ {"success": False, "error": "无法创建模块规范", "file_path": str(full_file_path)}
+ )
+ return
+
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[module_name] = module
+ spec.loader.exec_module(module)
+
+ await send_log(f"成功导入模块用于分析: {module_name}")
+
+ except Exception as e:
+ await send_analysis_result(
+ {"success": False, "error": f"导入模块失败: {str(e)}", "file_path": str(full_file_path)}
+ )
+ return
+
+ # 分析模块中的类
+ classes = []
+ for name in dir(module):
+ try:
+ obj = getattr(module, name)
+ if isinstance(obj, type) and obj.__module__ == module_name:
+ # 获取类的文档字符串
+ docstring = inspect.getdoc(obj) or ""
+ # 只取第一行作为简短描述
+ short_desc = docstring.split("\n")[0] if docstring else ""
+
+ classes.append({"name": name, "docstring": short_desc, "full_docstring": docstring})
+ except Exception as e:
+ await send_log(f"分析类 {name} 时出错: {str(e)}", "warning")
+ continue
+
+ if not classes:
+ await send_analysis_result(
+ {
+ "success": False,
+ "error": "模块中未找到任何类定义",
+ "file_path": str(full_file_path),
+ "module_name": module_name,
+ }
+ )
+ return
+
+ await send_log(f"找到 {len(classes)} 个类: {[cls['name'] for cls in classes]}")
+
+ # 发送分析结果
+ await send_analysis_result(
+ {"success": True, "file_path": str(full_file_path), "module_name": module_name, "classes": classes}
+ )
+
+ except Exception as e:
+ await send_analysis_result(
+ {
+ "success": False,
+ "error": f"分析过程中发生错误: {str(e)}",
+ "file_path": file_path,
+ "traceback": traceback.format_exc(),
+ }
+ )
+
+
+async def handle_file_content_analysis(websocket: WebSocket, request_data: dict):
+ """处理文件内容分析请求,直接分析上传的文件内容"""
+ import json
+ import os
+ import sys
+ import inspect
+ import traceback
+ import tempfile
+ from pathlib import Path
+
+ file_name = request_data.get("file_name")
+ file_content = request_data.get("file_content")
+ file_size = request_data.get("file_size", 0)
+
+ async def send_log(message: str, level: str = "info"):
+ """发送日志消息到客户端"""
+ try:
+ await websocket.send_text(json.dumps({"type": "log", "message": message, "level": level}))
+ except Exception as e:
+ print(f"Failed to send log: {e}")
+
+ async def send_analysis_result(result_data: dict):
+ """发送分析结果到客户端"""
+ try:
+ await websocket.send_text(json.dumps({"type": "file_analysis_result", "data": result_data}))
+ except Exception as e:
+ print(f"Failed to send analysis result: {e}")
+
+ try:
+ # 验证文件内容
+ if not file_name or not file_content:
+ await send_analysis_result({"success": False, "error": "文件名或文件内容为空", "file_name": file_name})
+ return
+
+ await send_log(f"开始分析文件: {file_name} ({file_size} 字节)")
+
+ # 验证文件是Python文件
+ if not file_name.endswith(".py"):
+ await send_analysis_result({"success": False, "error": "请选择Python文件 (.py)", "file_name": file_name})
+ return
+
+ # 创建临时文件
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as temp_file:
+ temp_file.write(file_content)
+ temp_file_path = temp_file.name
+
+ await send_log(f"创建临时文件: {temp_file_path}")
+
+ try:
+ # 添加临时文件目录到sys.path
+ temp_dir = str(Path(temp_file_path).parent)
+ if temp_dir not in sys.path:
+ sys.path.insert(0, temp_dir)
+ await send_log(f"已添加临时目录到sys.path: {temp_dir}")
+
+ # 确定模块名(去掉.py扩展名)
+ module_name = file_name.replace(".py", "").replace("-", "_").replace(" ", "_")
+ await send_log(f"使用模块名: {module_name}")
+
+ # 导入模块进行分析
+ try:
+ # 如果模块已经导入,先删除以便重新导入
+ if module_name in sys.modules:
+ del sys.modules[module_name]
+ await send_log(f"已删除旧模块: {module_name}")
+
+ import importlib.util
+
+ spec = importlib.util.spec_from_file_location(module_name, temp_file_path)
+ if spec is None or spec.loader is None:
+ raise Exception("无法创建模块规范")
+
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[module_name] = module
+ spec.loader.exec_module(module)
+
+ await send_log(f"成功导入模块用于分析: {module_name}")
+
+ except Exception as e:
+ await send_analysis_result(
+ {"success": False, "error": f"导入模块失败: {str(e)}", "file_name": file_name}
+ )
+ return
+
+ # 分析模块中的类
+ classes = []
+ for name in dir(module):
+ try:
+ obj = getattr(module, name)
+ if isinstance(obj, type) and obj.__module__ == module_name:
+ # 获取类的文档字符串
+ docstring = inspect.getdoc(obj) or ""
+ # 只取第一行作为简短描述
+ short_desc = docstring.split("\n")[0] if docstring else "无描述"
+
+ classes.append({"name": name, "docstring": short_desc, "full_docstring": docstring})
+ except Exception as e:
+ await send_log(f"分析类 {name} 时出错: {str(e)}", "warning")
+ continue
+
+ if not classes:
+ await send_analysis_result(
+ {
+ "success": False,
+ "error": "模块中未找到任何类定义",
+ "file_name": file_name,
+ "module_name": module_name,
+ }
+ )
+ return
+
+ await send_log(f"找到 {len(classes)} 个类: {[cls['name'] for cls in classes]}")
+
+ # 发送分析结果
+ await send_analysis_result(
+ {
+ "success": True,
+ "file_name": file_name,
+ "module_name": module_name,
+ "classes": classes,
+ "temp_file_path": temp_file_path, # 保存临时文件路径供后续使用
+ }
+ )
+
+ finally:
+ # 清理临时文件(在导入完成后再删除)
+ try:
+ if os.path.exists(temp_file_path):
+ # 延迟删除,给导入操作留出时间
+ import threading
+
+ def delayed_cleanup():
+ import time
+
+ time.sleep(60) # 等待60秒后删除
+ try:
+ os.unlink(temp_file_path)
+ except OSError:
+ pass
+
+ threading.Thread(target=delayed_cleanup, daemon=True).start()
+ except Exception as e:
+ await send_log(f"清理临时文件时出错: {str(e)}", "warning")
+
+ except Exception as e:
+ await send_analysis_result(
+ {
+ "success": False,
+ "error": f"分析过程中发生错误: {str(e)}",
+ "file_name": file_name,
+ "traceback": traceback.format_exc(),
+ }
+ )
+
+
+async def handle_file_content_import(websocket: WebSocket, request_data: dict):
+ """处理基于文件内容的导入请求"""
+ import json
+ import os
+ import sys
+ import traceback
+ import tempfile
+ from pathlib import Path
+
+ file_name = request_data.get("file_name")
+ file_content = request_data.get("file_content")
+ file_size = request_data.get("file_size", 0)
+ registry_type = request_data.get("registry_type", "device")
+ class_name = request_data.get("class_name")
+
+ async def send_log(message: str, level: str = "info"):
+ """发送日志消息到客户端"""
+ try:
+ await websocket.send_text(json.dumps({"type": "log", "message": message, "level": level}))
+ except Exception as e:
+ print(f"Failed to send log: {e}")
+
+ async def send_progress(message: str):
+ """发送进度消息到客户端"""
+ try:
+ await websocket.send_text(json.dumps({"type": "progress", "message": message}))
+ except Exception as e:
+ print(f"Failed to send progress: {e}")
+
+ async def send_error(message: str):
+ """发送错误消息到客户端"""
+ try:
+ await websocket.send_text(json.dumps({"type": "error", "message": message}))
+ except Exception as e:
+ print(f"Failed to send error: {e}")
+
+ async def send_result(result_data: dict):
+ """发送结果数据到客户端"""
+ try:
+ await websocket.send_text(json.dumps({"type": "result", "data": result_data}))
+ except Exception as e:
+ print(f"Failed to send result: {e}")
+
+ try:
+ # 验证输入参数
+ if not file_name or not file_content or not class_name:
+ await send_error("文件名、文件内容或类名为空")
+ return
+
+ await send_log(f"开始处理文件: {file_name} ({file_size} 字节)")
+ await send_progress("正在创建临时文件...")
+
+ # 创建临时文件
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as temp_file:
+ temp_file.write(file_content)
+ temp_file_path = temp_file.name
+
+ await send_log(f"创建临时文件: {temp_file_path}")
+
+ # 添加临时文件目录到sys.path
+ temp_dir = str(Path(temp_file_path).parent)
+ if temp_dir not in sys.path:
+ sys.path.insert(0, temp_dir)
+ await send_log(f"已添加临时目录到sys.path: {temp_dir}")
+
+ # 确定模块名
+ module_name = file_name.replace(".py", "").replace("-", "_").replace(" ", "_")
+ await send_log(f"使用模块名: {module_name}")
+
+ # 导入模块
+ try:
+ # 如果模块已经导入,先删除以便重新导入
+ if module_name in sys.modules:
+ del sys.modules[module_name]
+ await send_log(f"已删除旧模块: {module_name}")
+
+ import importlib.util
+
+ spec = importlib.util.spec_from_file_location(module_name, temp_file_path)
+ if spec is None or spec.loader is None:
+ await send_error("无法创建模块规范")
+ return
+
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[module_name] = module
+ spec.loader.exec_module(module)
+
+ await send_log(f"成功导入模块: {module_name}")
+
+ except Exception as e:
+ await send_error(f"导入模块失败: {str(e)}")
+ return
+
+ # 验证类存在
+ if not hasattr(module, class_name):
+ await send_error(f"模块中未找到类: {class_name}")
+ return
+
+ target_class = getattr(module, class_name)
+ await send_log(f"找到目标类: {class_name}")
+
+ # 使用registry.py的增强类信息功能进行分析
+ await send_progress("正在生成注册表信息...")
+
+ try:
+ from unilabos.utils.import_manager import get_enhanced_class_info
+
+ # 分析类信息
+ enhanced_info = get_enhanced_class_info(f"{module_name}:{class_name}", use_dynamic=True)
+
+ if not enhanced_info.get("dynamic_import_success", False):
+ await send_error("动态导入类信息失败")
+ return
+
+ await send_log("成功分析类信息")
+
+ # 生成注册表schema
+ registry_schema = {
+ "class_name": class_name,
+ "module": f"{module_name}:{class_name}",
+ "type": "python",
+ "description": enhanced_info.get("class_docstring", ""),
+ "version": "1.0.0",
+ "category": [registry_type],
+ "status_types": {k: v["return_type"] for k, v in enhanced_info["status_methods"].items()},
+ "action_value_mappings": {},
+ "init_param_schema": {},
+ "registry_type": registry_type,
+ "file_path": f"uploaded_file://{file_name}",
+ }
+
+ # 处理动作方法
+ for method_name, method_info in enhanced_info["action_methods"].items():
+ registry_schema["action_value_mappings"][f"auto-{method_name}"] = {
+ "type": "UniLabJsonCommandAsync" if method_info["is_async"] else "UniLabJsonCommand",
+ "goal": {},
+ "feedback": {},
+ "result": {},
+ "args": method_info["args"],
+ "description": method_info.get("docstring", ""),
+ }
+
+ await send_log("成功生成注册表schema")
+
+ # 准备结果数据
+ result = {
+ "class_info": {
+ "class_name": class_name,
+ "module_name": module_name,
+ "file_name": file_name,
+ "file_size": file_size,
+ "docstring": enhanced_info.get("class_docstring", ""),
+ "dynamic_import_success": enhanced_info.get("dynamic_import_success", False),
+ },
+ "registry_schema": registry_schema,
+ "action_methods": enhanced_info["action_methods"],
+ "status_methods": enhanced_info["status_methods"],
+ }
+
+ # 发送结果
+ await send_result(result)
+ await send_log("分析完成")
+
+ except Exception as e:
+ await send_error(f"分析类信息时发生错误: {str(e)}")
+ await send_log(f"详细错误信息: {traceback.format_exc()}")
+ return
+
+ finally:
+ # 清理临时文件
+ try:
+ if os.path.exists(temp_file_path):
+ import threading
+
+ def delayed_cleanup():
+ import time
+
+ time.sleep(30) # 等待30秒后删除
+ try:
+ os.unlink(temp_file_path)
+ except OSError:
+ pass
+
+ threading.Thread(target=delayed_cleanup, daemon=True).start()
+ except Exception as e:
+ await send_log(f"清理临时文件时出错: {str(e)}", "warning")
+
+ except Exception as e:
+ await send_error(f"处理过程中发生错误: {str(e)}")
+ await send_log(f"详细错误信息: {traceback.format_exc()}")
+
+
+async def handle_file_import(websocket: WebSocket, request_data: dict):
+ """处理文件导入请求"""
+ import json
+ import os
+ import sys
+ import traceback
+ from pathlib import Path
+ from unilabos.config.config import BasicConfig
+
+ file_path = request_data.get("file_path")
+ registry_type = request_data.get("registry_type", "device")
+ class_name = request_data.get("class_name")
+ module_name = request_data.get("module_name")
+
+ async def send_log(message: str, level: str = "info"):
+ """发送日志消息到客户端"""
+ try:
+ await websocket.send_text(json.dumps({"type": "log", "message": message, "level": level}))
+ except Exception as e:
+ print(f"Failed to send log: {e}")
+
+ async def send_progress(message: str):
+ """发送进度消息到客户端"""
+ try:
+ await websocket.send_text(json.dumps({"type": "progress", "message": message}))
+ except Exception as e:
+ print(f"Failed to send progress: {e}")
+
+ async def send_error(message: str):
+ """发送错误消息到客户端"""
+ try:
+ await websocket.send_text(json.dumps({"type": "error", "message": message}))
+ except Exception as e:
+ print(f"Failed to send error: {e}")
+
+ async def send_result(result_data: dict):
+ """发送结果数据到客户端"""
+ try:
+ await websocket.send_text(json.dumps({"type": "result", "data": result_data}))
+ except Exception as e:
+ print(f"Failed to send result: {e}")
+
+ try:
+ # 验证文件路径参数
+ if not file_path:
+ await send_error("文件路径为空")
+ return
+
+ # 获取工作目录并构建完整路径
+ working_dir_str = getattr(BasicConfig, "working_dir", None) or os.getcwd()
+ working_dir = Path(working_dir_str)
+ full_file_path = working_dir / file_path
+
+ # 验证文件路径
+ if not full_file_path.exists():
+ await send_error(f"文件路径不存在: {file_path}")
+ return
+
+ await send_log(f"开始处理文件: {file_path}")
+ await send_progress("正在验证文件...")
+
+ # 验证文件是Python文件
+ if not file_path.endswith(".py"):
+ await send_error("请选择Python文件 (.py)")
+ return
+
+ full_file_path = full_file_path.absolute()
+ await send_log(f"文件绝对路径: {full_file_path}")
+
+ # 动态导入模块
+ await send_progress("正在导入模块...")
+
+ # 添加文件目录到sys.path
+ file_dir = str(full_file_path.parent)
+ if file_dir not in sys.path:
+ sys.path.insert(0, file_dir)
+ await send_log(f"已添加路径到sys.path: {file_dir}")
+
+ # 确定模块名
+ if not module_name:
+ module_name = full_file_path.stem
+ await send_log(f"使用模块名: {module_name}")
+
+ # 导入模块
+ try:
+ # 如果模块已经导入,先删除以便重新导入
+ if module_name in sys.modules:
+ del sys.modules[module_name]
+ await send_log(f"已删除旧模块: {module_name}")
+
+ import importlib.util
+
+ spec = importlib.util.spec_from_file_location(module_name, full_file_path)
+ if spec is None or spec.loader is None:
+ await send_error("无法创建模块规范")
+ return
+
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[module_name] = module
+ spec.loader.exec_module(module)
+
+ await send_log(f"成功导入模块: {module_name}")
+
+ except Exception as e:
+ await send_error(f"导入模块失败: {str(e)}")
+ return
+
+ # 分析模块
+ await send_progress("正在分析模块...")
+
+ # 获取模块中的所有类
+ classes = []
+ for name in dir(module):
+ obj = getattr(module, name)
+ if isinstance(obj, type) and obj.__module__ == module_name:
+ classes.append((name, obj))
+
+ if not classes:
+ await send_error("模块中未找到任何类定义")
+ return
+
+ await send_log(f"找到 {len(classes)} 个类: {[name for name, _ in classes]}")
+
+ # 确定要分析的类
+ target_class = None
+ target_class_name = None
+
+ if class_name:
+ # 用户指定了类名
+ for name, cls in classes:
+ if name == class_name:
+ target_class = cls
+ target_class_name = name
+ break
+ if not target_class:
+ await send_error(f"未找到指定的类: {class_name}")
+ return
+ else:
+ # 自动选择第一个类
+ target_class_name, target_class = classes[0]
+ await send_log(f"自动选择类: {target_class_name}")
+
+ # 使用registry.py的增强类信息功能进行分析
+ await send_progress("正在生成注册表信息...")
+
+ try:
+ from unilabos.utils.import_manager import get_enhanced_class_info
+
+ # 分析类信息
+ enhanced_info = get_enhanced_class_info(f"{module_name}:{target_class_name}", use_dynamic=True)
+
+ if not enhanced_info.get("dynamic_import_success", False):
+ await send_error("动态导入类信息失败")
+ return
+
+ await send_log("成功分析类信息")
+
+ # 生成注册表schema
+ registry_schema = {
+ "class_name": target_class_name,
+ "module": f"{module_name}:{target_class_name}",
+ "type": "python",
+ "description": enhanced_info.get("class_docstring", ""),
+ "version": "1.0.0",
+ "category": [registry_type],
+ "status_types": {k: v["return_type"] for k, v in enhanced_info["status_methods"].items()},
+ "action_value_mappings": {},
+ "init_param_schema": {},
+ "registry_type": registry_type,
+ "file_path": str(full_file_path),
+ }
+
+ # 处理动作方法
+ for method_name, method_info in enhanced_info["action_methods"].items():
+ registry_schema["action_value_mappings"][f"auto-{method_name}"] = {
+ "type": "UniLabJsonCommandAsync" if method_info["is_async"] else "UniLabJsonCommand",
+ "goal": {},
+ "feedback": {},
+ "result": {},
+ "args": method_info["args"],
+ "description": method_info.get("docstring", ""),
+ }
+
+ await send_log("成功生成注册表schema")
+
+ # 转换为YAML格式
+ import yaml
+ from unilabos.utils.type_check import NoAliasDumper
+
+ # 创建最终的YAML配置(使用设备ID作为根键)
+ class_name_safe = class_name or "unknown"
+ suffix = "_device" if registry_type == "device" else "_resource"
+ device_id = f"{class_name_safe.lower()}{suffix}"
+ final_config = {device_id: registry_schema}
+
+ yaml_content = yaml.dump(
+ final_config, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper, sort_keys=True
+ )
+
+ # 准备结果数据(只保留YAML结果)
+ result = {
+ "registry_schema": yaml_content,
+ "device_id": device_id,
+ "class_name": class_name,
+ "module_name": module_name,
+ "file_path": file_path,
+ }
+
+ # 发送结果
+ await send_result(result)
+ await send_log("注册表生成完成")
+
+ except Exception as e:
+ await send_error(f"分析类信息时发生错误: {str(e)}")
+ await send_log(f"详细错误信息: {traceback.format_exc()}")
+ return
+
+ except Exception as e:
+ await send_error(f"处理过程中发生错误: {str(e)}")
+ await send_log(f"详细错误信息: {traceback.format_exc()}")
+
+
+@api.get("/file-browser", summary="Browse files and directories", response_model=Resp)
+def get_file_browser_data(path: str = ""):
+ """获取文件浏览器数据"""
+ import os
+ from pathlib import Path
+ from unilabos.config.config import BasicConfig
+
+ try:
+ # 获取工作目录
+ working_dir_str = getattr(BasicConfig, "working_dir", None) or os.getcwd()
+ working_dir = Path(working_dir_str)
+
+ # 如果提供了相对路径,则在工作目录下查找
+ if path:
+ target_path = working_dir / path
+ else:
+ target_path = working_dir
+
+ # 确保路径在工作目录内(安全检查)
+ target_path = target_path.resolve()
+
+ if not target_path.exists():
+ return Resp(code=RespCode.ErrorInvalidReq, message=f"路径不存在: {path}")
+
+ if not target_path.is_dir():
+ return Resp(code=RespCode.ErrorInvalidReq, message=f"不是目录: {path}")
+
+ # 获取目录内容
+ items = []
+
+ parent_path = target_path.parent
+ relative_parent = parent_path.relative_to(working_dir)
+ items.append(
+ {
+ "name": "..",
+ "type": "directory",
+ "path": str(relative_parent) if relative_parent != Path(".") else "",
+ "size": 0,
+ "is_parent": True,
+ }
+ )
+
+ # 获取子目录和文件
+ try:
+ for item in sorted(target_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
+ if item.name.startswith("."): # 跳过隐藏文件
+ continue
+
+ item_type = "directory" if item.is_dir() else "file"
+ relative_path = item.relative_to(working_dir)
+
+ item_info = {
+ "name": item.name,
+ "type": item_type,
+ "path": str(relative_path),
+ "size": item.stat().st_size if item.is_file() else 0,
+ "is_python": item.suffix == ".py" if item.is_file() else False,
+ "is_parent": False,
+ }
+ items.append(item_info)
+ except PermissionError:
+ return Resp(code=RespCode.ErrorInvalidReq, message="无权限访问此目录")
+
+ return Resp(
+ data={
+ "current_path": str(target_path.relative_to(working_dir)) if target_path != working_dir else "",
+ "working_dir": str(working_dir),
+ "items": items,
+ }
+ )
+
+ except Exception as e:
+ return Resp(code=RespCode.ErrorInvalidReq, message=f"获取目录信息失败: {str(e)}")
+
+
@api.get("/resources", summary="Resource list", response_model=Resp)
def get_resources():
"""获取资源列表"""
@@ -82,18 +1088,6 @@ def get_resources():
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():
"""获取设备列表"""
@@ -104,12 +1098,6 @@ def get_devices():
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):
"""获取任务状态"""
@@ -129,63 +1117,6 @@ def post_job_add(req: JobAddReq):
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"])
@@ -195,3 +1126,4 @@ def setup_api_routes(app):
@app.on_event("startup")
async def startup_event():
asyncio.create_task(broadcast_device_status())
+ asyncio.create_task(broadcast_status_page_data())
diff --git a/unilabos/app/web/pages.py b/unilabos/app/web/pages.py
index 9f9a6c0a..2887c2a0 100644
--- a/unilabos/app/web/pages.py
+++ b/unilabos/app/web/pages.py
@@ -78,21 +78,23 @@ def setup_web_pages(router: APIRouter) -> None:
HTMLResponse: 渲染后的HTML页面
"""
try:
- # 准备设备数据
+ # 准备初始数据结构(这些数据将通过WebSocket实时更新)
devices = []
resources = []
modules = {"names": [], "classes": [], "displayed_count": 0, "total_count": 0}
- # 获取在线设备信息
+ # 获取在线设备信息(用于初始渲染)
ros_node_info = get_ros_node_info()
- # 获取主机节点信息
+ # 获取主机节点信息(用于初始渲染)
host_node_info = get_host_node_info()
- # 获取Registry路径信息
+ # 获取Registry路径信息(静态信息,不需要实时更新)
registry_info = get_registry_info()
- # 获取已加载的设备
+ # 获取初始数据用于页面渲染(后续将被WebSocket数据覆盖)
if lab_registry:
- devices = json.loads(json.dumps(lab_registry.obtain_registry_device_info(), ensure_ascii=False, cls=TypeEncoder))
+ devices = json.loads(
+ json.dumps(lab_registry.obtain_registry_device_info(), ensure_ascii=False, cls=TypeEncoder)
+ )
# 资源类型
for resource_id, resource_info in lab_registry.resource_type_registry.items():
resources.append(
@@ -103,7 +105,7 @@ def setup_web_pages(router: APIRouter) -> None:
}
)
- # 获取导入的模块
+ # 获取导入的模块(初始数据)
if msg_converter_manager:
modules["names"] = msg_converter_manager.list_modules()
all_classes = [i for i in msg_converter_manager.list_classes() if "." in i]
@@ -171,3 +173,20 @@ def setup_web_pages(router: APIRouter) -> None:
except Exception as e:
error(f"打开文件夹时出错: {str(e)}")
return {"status": "error", "message": f"Failed to open folder: {str(e)}"}
+
+ @router.get("/registry-editor", response_class=HTMLResponse, summary="Registry Editor")
+ async def registry_editor_page() -> str:
+ """
+ 注册表编辑页面,用于导入Python文件并生成注册表
+
+ Returns:
+ HTMLResponse: 渲染后的HTML页面
+ """
+ try:
+ # 使用模板渲染页面
+ template = env.get_template("registry_editor.html")
+ html = template.render()
+ return html
+ except Exception as e:
+ error(f"生成注册表编辑页面时出错: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Error generating registry editor page: {str(e)}")
diff --git a/unilabos/app/web/templates/base.html b/unilabos/app/web/templates/base.html
index 8e3a31d7..cb3e1a4e 100644
--- a/unilabos/app/web/templates/base.html
+++ b/unilabos/app/web/templates/base.html
@@ -162,7 +162,6 @@
{% block header %}UniLab{% endblock %}
{% block nav %}
- Home
{% endblock %}
{% block top_info %}{% endblock %}
diff --git a/unilabos/app/web/templates/home.html b/unilabos/app/web/templates/home.html
index a95f9d6a..2517f1ce 100644
--- a/unilabos/app/web/templates/home.html
+++ b/unilabos/app/web/templates/home.html
@@ -1,22 +1,25 @@
-{% extends "base.html" %}
-
-{% block title %}UniLab API{% endblock %}
-
-{% block header %}UniLab API{% endblock %}
-
-{% block nav %}
-System Status
-{% endblock %}
-
-{% block content %}
-
-
Available Endpoints
- {% for route in routes %}
-
- {% endfor %}
+{% extends "base.html" %} {% block title %}UniLab API{% endblock %} {% block
+header %}UniLab API{% endblock %} {% block nav %}
+
-{% endblock %}
\ No newline at end of file
+{% endblock %} {% block content %}
+
+
Available Endpoints
+ {% for route in routes %}
+
+ {% endfor %}
+
+{% endblock %}
diff --git a/unilabos/app/web/templates/registry_editor.html b/unilabos/app/web/templates/registry_editor.html
new file mode 100644
index 00000000..8e23b398
--- /dev/null
+++ b/unilabos/app/web/templates/registry_editor.html
@@ -0,0 +1,1085 @@
+{% extends "base.html" %} {% block title %}注册表编辑器 - UniLab{% endblock %}
+{% block header %}注册表编辑器{% endblock %} {% block nav %}
+{% endblock %} {% block scripts %}
+
+{% endblock %} {% block content %}
+
+
+
+{% endblock %}
diff --git a/unilabos/app/web/templates/status.html b/unilabos/app/web/templates/status.html
index e1105b7b..44412a2c 100644
--- a/unilabos/app/web/templates/status.html
+++ b/unilabos/app/web/templates/status.html
@@ -1,1255 +1,2375 @@
-{% extends "base.html" %}
+{% extends "base.html" %} {% block title %}UniLab System Status{% endblock %} {%
+block header %}UniLab System Status{% endblock %} {% block top_info %}
+
+
-{% block title %}UniLab System Status{% endblock %}
-
-{% block header %}UniLab System Status{% endblock %}
-
-{% block top_info %}
-
- 系统模式: {{ "主机模式 (HOST)" if is_host_mode else "从机模式 (SLAVE)" }}
-
+
+ 系统模式:
+ {{ "主机模式 (HOST)" if is_host_mode else "从机模式 (SLAVE)" }}
+
{% if registry_info %}
- {% if registry_info.paths %}
-
-
注册表路径:
-
- {% for path in registry_info.paths %}
- -
- {{ path }}
- 📁
-
- {% endfor %}
-
-
- {% endif %}
-
- {% if registry_info.devices_paths %}
-
-
设备目录:
-
- {% for path in registry_info.devices_paths %}
- -
- {{ path }}
- 📁
-
- {% endfor %}
-
-
- {% endif %}
-
- {% if registry_info.device_comms_paths %}
-
-
设备通信目录:
-
- {% for path in registry_info.device_comms_paths %}
- -
- {{ path }}
- 📁
-
- {% endfor %}
-
-
- {% endif %}
-
- {% if registry_info.resources_paths %}
-
-
资源目录:
-
- {% for path in registry_info.resources_paths %}
- -
- {{ path }}
- 📁
-
- {% endfor %}
-
-
- {% endif %}
+ {% if registry_info.paths %}
+
+
注册表路径:
+
+ {% for path in registry_info.paths %}
+ -
+ {{ path }}
+ 📁
+
+ {% endfor %}
+
+
+ {% endif %} {% if registry_info.devices_paths %}
+
+
设备目录:
+
+ {% for path in registry_info.devices_paths %}
+ -
+ {{ path }}
+ 📁
+
+ {% endfor %}
+
+
+ {% endif %} {% if registry_info.device_comms_paths %}
+
+
设备通信目录:
+
+ {% for path in registry_info.device_comms_paths %}
+ -
+ {{ path }}
+ 📁
+
+ {% endfor %}
+
+
+ {% endif %} {% if registry_info.resources_paths %}
+
+
资源目录:
+
+ {% for path in registry_info.resources_paths %}
+ -
+ {{ path }}
+ 📁
+
+ {% endfor %}
+
+
+ {% endif %}
{% endif %}
-{% endblock %}
-
-{% block content %}
+{% endblock %} {% block content %}
{% if is_host_mode and host_node_info.available %}
-
主机节点信息
-
-
-
-
已管理设备 {{ host_node_info.devices|length }}
-
-
- | 设备ID |
- 命名空间 |
- 机器名称 |
- 状态 |
-
- {% for device_id, device_info in host_node_info.devices.items() %}
-
- | {{ device_id }} |
- {{ device_info.namespace }} |
- {{ device_info.machine_name }} |
- {{ "在线" if device_info.is_online else "离线" }} |
-
- {% else %}
-
- | 没有发现已管理的设备 |
-
- {% endfor %}
-
-
-
-
-
-
动作客户端 {{ host_node_info.action_clients|length }}
-
- 已接纳动作:
-
-
- | 话题 |
- 类型 |
- |
-
- {% for action_name, action_info in host_node_info.action_clients.items() %}
-
- | {{ action_name }} |
- {{ action_info.type_name }} |
- ▼ |
-
-
-
-
- 发送命令:
-
- ros2 action send_goal {{ action_info.action_path }} {{ action_info.type_name_convert }} "{{ action_info.goal_info }}"
-
-
-
- 提示: 根据目标结构修改命令参数
-
- |
-
- {% endfor %}
-
-
-
-
-
-
-
已订阅主题 {{ host_node_info.subscribed_topics|length }}
-
- {% if host_node_info.subscribed_topics %}
-
- {% for topic in host_node_info.subscribed_topics %}
-
- {{ topic }}
-
-
- {% endfor %}
+
主机节点信息
+
+
+
+
+ 已管理设备
+ {{ host_node_info.devices|length }}
+
+
+
+ | 设备ID |
+ 命名空间 |
+ 机器名称 |
+ 状态 |
+
+ {% for device_id, device_info in host_node_info.devices.items() %}
+
+ | {{ device_id }} |
+ {{ device_info.namespace }} |
+ {{ device_info.machine_name }} |
+
+ {{ "在线" if device_info.is_online else "离线" }}
+ |
+
+ {% else %}
+
+ | 没有发现已管理的设备 |
+
+ {% endfor %}
+
+
+
+
+
+
+ 动作客户端
+ {{ host_node_info.action_clients|length }}
+
+
+ 已接纳动作:
+
+
+ | 话题 |
+ 类型 |
+ |
+
+ {% for action_name, action_info in host_node_info.action_clients.items()
+ %}
+
+ | {{ action_name }} |
+ {{ action_info.type_name }} |
+ ▼ |
+
+
+
+
+ 发送命令:
+
+
+ros2 action send_goal {{ action_info.action_path }} {{ action_info.type_name_convert }} "{{ action_info.goal_info }}"
+
+
+
+ 提示: 根据目标结构修改命令参数
- {% else %}
- 没有发现已订阅的主题
- {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+
+ 已订阅主题
+ {{ host_node_info.subscribed_topics|length }}
+
+
+ {% if host_node_info.subscribed_topics %}
+
+ {% for topic in host_node_info.subscribed_topics %}
+
+ {{ topic }}
+
+ {% endfor %}
+
+ {% else %}
+
没有发现已订阅的主题
+ {% endif %}
-
-
- {% if host_node_info.device_status %}
-
-
设备状态
-
-
- | 设备ID |
- 属性 |
- 值 |
- 最后更新 |
-
- {% for device_id, properties in host_node_info.device_status.items() %}
- {% for prop_name, prop_value in properties.items() %}
-
- {% if loop.first %}
- | {{ device_id }} |
- {% endif %}
- {{ prop_name }} |
- {{ prop_value }} |
-
- {% if device_id in host_node_info.device_status_timestamps and prop_name in host_node_info.device_status_timestamps[device_id] %}
- {% set ts_info = host_node_info.device_status_timestamps[device_id][prop_name] %}
- {% if ts_info.elapsed >= 0 %}
- {{ ts_info.elapsed }} 秒前
- {% else %}
- 未更新
- {% endif %}
- {% else %}
- 无数据
- {% endif %}
- |
-
- {% endfor %}
- {% else %}
-
- | 没有设备状态数据 |
-
- {% endfor %}
-
-
- {% endif %}
+
+
+
+ {% if host_node_info.device_status %}
+
+
设备状态
+
+
+ | 设备ID |
+ 属性 |
+ 值 |
+ 最后更新 |
+
+ {% for device_id, properties in host_node_info.device_status.items() %} {%
+ for prop_name, prop_value in properties.items() %}
+
+ {% if loop.first %}
+ | {{ device_id }} |
+ {% endif %}
+ {{ prop_name }} |
+ {{ prop_value }} |
+
+ {% if device_id in host_node_info.device_status_timestamps and
+ prop_name in host_node_info.device_status_timestamps[device_id] %} {%
+ set ts_info =
+ host_node_info.device_status_timestamps[device_id][prop_name] %} {% if
+ ts_info.elapsed >= 0 %}
+ {{ ts_info.elapsed }} 秒前
+ {% else %}
+ 未更新
+ {% endif %} {% else %}
+ 无数据
+ {% endif %}
+ |
+
+ {% endfor %} {% else %}
+
+ | 没有设备状态数据 |
+
+ {% endfor %}
+
+
+ {% endif %}
{% endif %}
-
Local Devices
-
-
- | Device ID |
- 节点名称 |
- 命名空间 |
- 机器名称 |
- 状态项 |
- 动作数 |
-
- {% for device_id, device_info in ros_node_info.registered_devices.items() %}
- {% set device_loop_index = loop.index %}
-
- | {{ device_id }} |
- {{ device_info.node_name }} |
- {{ device_info.namespace }} |
- {{ device_info.machine_name|default("本地") }} |
- {{ ros_node_info.device_topics.get(device_id, {})|length }} |
- {{ ros_node_info.device_actions.get(device_id, {})|length }} ▼ |
-
-
-
-
- UUID: {{ device_info.uuid }}
- {% if device_id in ros_node_info.device_topics %}
- 已发布状态:
-
-
- | 名称 |
- 类型 |
- 话题 |
- 间隔 |
- |
-
- {% for status_name, status_info in ros_node_info.device_topics[device_id].items() %}
-
- | {{ status_name }} |
- {{ status_info.type_name }} |
- {{ status_info.topic_path }} |
- {{ status_info.timer_period }} |
- ▼ |
-
-
-
-
- 订阅命令:
-
- ros2 topic echo {{ status_info.topic_path }}
-
-
-
- |
-
- {% endfor %}
-
- {% endif %}
-
- {% if device_id in ros_node_info.device_actions %}
- 已发布动作:
-
-
- | 名称 |
- 类型 |
- 话题 |
- |
-
- {% for action_name, action_info in ros_node_info.device_actions[device_id].items() %}
-
- | {{ action_name }} |
- {{ action_info.type_name }} |
- {{ action_info.action_path }} |
- ▼ |
-
-
-
-
- 发送命令:
-
- ros2 action send_goal {{ action_info.action_path }} {{ action_info.type_name_convert }} "{{ action_info.goal_info }}"
-
-
-
- 提示: 根据目标结构修改命令参数
-
- |
-
- {% endfor %}
-
- {% endif %}
+ Local Devices
+
+
+ | Device ID |
+ 节点名称 |
+ 命名空间 |
+ 机器名称 |
+ 状态项 |
+ 动作数 |
+
+ {% for device_id, device_info in ros_node_info.registered_devices.items() %}
+ {% set device_loop_index = loop.index %}
+
+ | {{ device_id }} |
+ {{ device_info.node_name }} |
+ {{ device_info.namespace }} |
+ {{ device_info.machine_name|default("本地") }} |
+ {{ ros_node_info.device_topics.get(device_id, {})|length }} |
+
+ {{ ros_node_info.device_actions.get(device_id, {})|length }}
+ ▼
+ |
+
+
+
+
+ UUID: {{ device_info.uuid }} {% if device_id in
+ ros_node_info.device_topics %}
+ 已发布状态:
+
+
+ | 名称 |
+ 类型 |
+ 话题 |
+ 间隔 |
+ |
+
+ {% for status_name, status_info in
+ ros_node_info.device_topics[device_id].items() %}
+
+ | {{ status_name }} |
+ {{ status_info.type_name }} |
+ {{ status_info.topic_path }} |
+ {{ status_info.timer_period }} |
+ ▼ |
+
+
+
+
+ 订阅命令:
+
+ ros2 topic echo {{ status_info.topic_path }}
+
+
- |
-
- {% endfor %}
-
+ |
+
+ {% endfor %}
+
+ {% endif %} {% if device_id in ros_node_info.device_actions %}
+ 已发布动作:
+
+
+ | 名称 |
+ 类型 |
+ 话题 |
+ |
+
+ {% for action_name, action_info in
+ ros_node_info.device_actions[device_id].items() %}
+
+ | {{ action_name }} |
+ {{ action_info.type_name }} |
+ {{ action_info.action_path }} |
+ ▼ |
+
+
+
+
+ 发送命令:
+
+
+ros2 action send_goal {{ action_info.action_path }} {{ action_info.type_name_convert }} "{{ action_info.goal_info }}"
+
+
+
+ 提示: 根据目标结构修改命令参数
+
+ |
+
+ {% endfor %}
+
+ {% endif %}
+
+ |
+
+ {% endfor %}
+
-
Device Types
+
+
-
- | ID |
- Name |
- File Path |
- |
-
- {% for device in devices %}
-
- | {{ device.id }} |
- {{ device.name }} |
-
- {{ device.file_path }}
- 📁
- |
- ▼ |
-
-
-
-
- {% if device.class %}
- {{ device.class | tojson(indent=4) }}
- {% else %}
-
- // No data
- {% endif %}
+
+ | ID |
+ Name |
+ File Path |
+ |
+
+ {% for device in devices %}
+
+ | {{ device.id }} |
+ {{ device.name }} |
+
+ {{ device.file_path }}
+ 📁
+ |
+ ▼ |
+
+
+
+
+ {% if device.class %}
+ {{ device.class | tojson(indent=4) }}
+ {% else %}
+
+ // No data
+ {% endif %} {% if device.is_online %}
+
+ 在线
+
+ {% endif %} {% if device.is_online and device.status_publishers %}
+ 状态发布者:
+
+ {% for status_name, status_info in
+ device.status_publishers.items() %}
+ -
+ {{ status_name }} - 类型: {{ status_info.type
+ }}
话题: {{ status_info.topic }}
+
+ {% endfor %}
+
+ {% endif %} {% if device.is_online and device.actions %}
+ 可用动作:
+
+ {% for action_name, action_info in device.actions.items() %}
+ -
+ {{ action_name }} - 类型: {{ action_info.type
+ }}
话题: {{ action_info.topic }}
+
+
+ 发送命令:
+
+ {{ action_info.command }}
+
+
+
+ {% if action_info %}
+ {{ action_info | tojson(indent=4) }}
+ {% else %}
+
+ // No data
+ {% endif %}
+
+
- {% if device.is_online %}
- 在线
- {% endif %}
-
- {% if device.is_online and device.status_publishers %}
- 状态发布者:
-
- {% for status_name, status_info in device.status_publishers.items() %}
- -
- {{ status_name }} - 类型: {{ status_info.type }}
-
话题: {{ status_info.topic }}
-
- {% endfor %}
-
- {% endif %}
-
- {% if device.is_online and device.actions %}
- 可用动作:
-
- {% for action_name, action_info in device.actions.items() %}
- -
- {{ action_name }} - 类型: {{ action_info.type }}
-
话题: {{ action_info.topic }}
-
-
- 发送命令:
-
- {{ action_info.command }}
-
-
-
- {% if action_info %}
- {{ action_info | tojson(indent=4) }}
- {% else %}
-
- // No data
- {% endif %}
-
-
-
- 提示: 根据目标结构修改命令参数
-
-
- {% endfor %}
-
- {% endif %}
+ 提示: 根据目标结构修改命令参数
- |
-
- {% endfor %}
+
+ {% endfor %}
+
+ {% endif %}
+
+ |
+
+ {% endfor %}
+
-
Resource Types
+
+
-
- | ID |
- Name |
- File Path |
-
- {% for resource in resources %}
-
- | {{ resource.id }} |
- {{ resource.name }} |
-
- {{ resource.file_path }}
- 📁
- |
-
- {% endfor %}
+
+ | ID |
+ Name |
+ File Path |
+
+ {% for resource in resources %}
+
+ | {{ resource.id }} |
+ {{ resource.name }} |
+
+ {{ resource.file_path }}
+ 📁
+ |
+
+ {% endfor %}
+
-
Converter Modules
+
+
Loaded Modules
-
- | Module Path |
-
- {% for module in modules.names %}
-
- | {{ module }} |
-
- {% endfor %}
+
+ | Module Path |
+
+ {% for module in modules.names %}
+
+ | {{ module }} |
+
+ {% endfor %}
-
Available Classes
- ({{ modules.total_count }})
+
+ Available Classes
+ ({{ modules.total_count }})
-
- | Class Name |
-
- {% for class_name in modules.classes %}
-
- | {{ class_name }} |
-
- {% endfor %}
+
+ | Class Name |
+
+ {% for class_name in modules.classes %}
+
+ | {{ class_name }} |
+
+ {% endfor %}
+
-{% endblock %}
-
-{% block scripts %}
-{{ super() }}
+{% endblock %} {% block scripts %} {{ super() }}
-{% endblock %}
\ No newline at end of file
+{% endblock %}