完善任务库位处理模块

This commit is contained in:
靳中伟 2025-07-16 10:50:00 +08:00
parent d8c31ebd5b
commit 7ebf10fc12
20 changed files with 4363 additions and 28 deletions

View File

@ -14,6 +14,8 @@
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| 1 | 任务执行结果实时推送 | `/task-execution/{task_record_id}` | WebSocket | 实时推送指定任务记录的执行结果更新 | | 1 | 任务执行结果实时推送 | `/task-execution/{task_record_id}` | WebSocket | 实时推送指定任务记录的执行结果更新 |
| 2 | 任务执行结果广播 | `/task-execution-broadcast/{task_record_id}` | WebSocket | 接收任务执行结果广播消息 | | 2 | 任务执行结果广播 | `/task-execution-broadcast/{task_record_id}` | WebSocket | 接收任务执行结果广播消息 |
| 3 | 库位状态实时推送 | `/storage-location/{scene_id}` | WebSocket | 实时推送指定场景的库位状态更新 |
| 4 | 库位状态广播 | `/storage-location-broadcast/{scene_id}` | WebSocket | 接收库位状态广播消息 |
## 接口详情 ## 接口详情
@ -252,6 +254,335 @@ ws://your-domain/ws/task-execution-broadcast/{task_record_id}
2. **日志收集**:收集任务执行过程中的状态变化记录 2. **日志收集**:收集任务执行过程中的状态变化记录
3. **事件通知**:当任务状态发生变化时接收通知 3. **事件通知**:当任务状态发生变化时接收通知
### 3. 库位状态实时推送
#### 接口说明
建立WebSocket连接实时接收指定场景的库位状态更新。服务器会定期推送库位状态变化客户端也可以主动请求获取当前状态。支持多种过滤条件来筛选特定的库位。
#### 连接路径
```
ws://your-domain/ws/storage-location/{scene_id}?interval={interval}&storage_area_id={storage_area_id}&station_name={station_name}&layer_name={layer_name}&is_occupied={is_occupied}&is_locked={is_locked}&is_disabled={is_disabled}
```
#### 路径参数
| 参数名 | 类型 | 是否必须 | 描述 |
| --- | --- | --- | --- |
| scene_id | string | 是 | 场景ID |
#### 查询参数
| 参数名 | 类型 | 是否必须 | 默认值 | 描述 |
| --- | --- | --- | --- | --- |
| interval | integer | 否 | 3 | 推送间隔范围1-30秒 |
| storage_area_id | string | 否 | null | 库区ID用于过滤特定库区 |
| station_name | string | 否 | null | 站点名称,用于过滤特定站点 |
| layer_name | string | 否 | null | 层名称,用于过滤特定层 |
| is_occupied | boolean | 否 | null | 是否占用过滤 |
| is_locked | boolean | 否 | null | 是否锁定过滤 |
| is_disabled | boolean | 否 | null | 是否禁用过滤 |
#### 客户端消息格式
客户端可以向服务器发送以下格式的JSON消息
##### 心跳检测
```json
{
"type": "ping",
"timestamp": "2025-06-11T12:00:00.000Z"
}
```
##### 获取当前状态
```json
{
"type": "get_status",
"timestamp": "2025-06-11T12:00:00.000Z"
}
```
#### 服务器消息格式
##### 库位状态更新
```json
{
"type": "storage_location_update",
"scene_id": "场景ID",
"timestamp": "2025-06-11T12:00:00.000Z",
"message": "成功获取库位状态",
"data": {
"total": 100,
"page": 1,
"page_size": 1000,
"total_pages": 1,
"storage_locations": [
{
"id": "层ID",
"layer_index": 1,
"layer_name": "层名称",
"operate_point_id": "动作点ID",
"station_name": "站点名称",
"storage_location_name": "库位名称",
"scene_id": "场景ID",
"storage_area_id": "库区ID",
"area_name": "库区名称",
"is_occupied": false,
"is_locked": false,
"is_disabled": false,
"is_empty_tray": false,
"locked_by": null,
"goods_content": "",
"goods_weight": null,
"goods_volume": null,
"goods_stored_at": null,
"goods_retrieved_at": null,
"last_access_at": "2025-06-11T12:00:00.000Z",
"max_weight": 5000,
"max_volume": 1000,
"layer_height": 100,
"tags": "",
"description": null,
"created_at": "2025-06-11T12:00:00.000Z",
"updated_at": "2025-06-11T12:00:00.000Z"
}
]
}
}
```
##### 库位状态变化通知
```json
{
"type": "storage_location_status_change",
"scene_id": "场景ID",
"layer_name": "层名称",
"action": "OCCUPY",
"timestamp": "2025-06-11T12:00:00.000Z",
"new_status": {
"id": "层ID",
"is_occupied": true,
"is_locked": false,
"is_disabled": false,
"is_empty_tray": false,
"locked_by": null,
"goods_content": "货物内容",
"last_access_at": "2025-06-11T12:00:00.000Z",
"updated_at": "2025-06-11T12:00:00.000Z"
}
}
```
##### 心跳响应
```json
{
"type": "pong",
"timestamp": "2025-06-11T12:00:00.000Z"
}
```
##### 错误消息
```json
{
"type": "error",
"scene_id": "场景ID",
"timestamp": "2025-06-11T12:00:00.000Z",
"message": "错误描述信息"
}
```
#### 响应字段说明
##### 库位状态字段
| 字段名 | 类型 | 描述 |
| --- | --- | --- |
| id | string | 层ID |
| layer_index | integer | 层索引(从1开始) |
| layer_name | string | 层名称 |
| operate_point_id | string | 动作点ID |
| station_name | string | 站点名称 |
| storage_location_name | string | 库位名称 |
| scene_id | string | 场景ID |
| storage_area_id | string | 库区ID |
| area_name | string | 库区名称 |
| is_occupied | boolean | 是否占用 |
| is_locked | boolean | 是否锁定 |
| is_disabled | boolean | 是否禁用 |
| is_empty_tray | boolean | 是否空托盘 |
| locked_by | string | 锁定者 |
| goods_content | string | 货物内容 |
| goods_weight | integer | 货物重量(克) |
| goods_volume | integer | 货物体积(立方厘米) |
| goods_stored_at | string | 货物存放时间 |
| goods_retrieved_at | string | 货物取出时间 |
| last_access_at | string | 最后访问时间 |
| max_weight | integer | 最大承重(克) |
| max_volume | integer | 最大体积(立方厘米) |
| layer_height | integer | 层高(毫米) |
| tags | string | 标签 |
| description | string | 层描述 |
| created_at | string | 创建时间 |
| updated_at | string | 更新时间 |
#### 连接示例
##### JavaScript客户端示例
```javascript
// 建立WebSocket连接
const sceneId = "your-scene-id";
const interval = 3; // 推送间隔3秒
const storageAreaId = "area-001"; // 过滤特定库区
const wsUrl = `ws://localhost:8000/ws/storage-location/${sceneId}?interval=${interval}&storage_area_id=${storageAreaId}&is_occupied=false`;
const websocket = new WebSocket(wsUrl);
// 连接建立
websocket.onopen = function(event) {
console.log("库位状态WebSocket连接已建立");
// 发送心跳包
websocket.send(JSON.stringify({
type: "ping",
timestamp: new Date().toISOString()
}));
};
// 接收消息
websocket.onmessage = function(event) {
const data = JSON.parse(event.data);
switch(data.type) {
case "storage_location_update":
console.log("库位状态更新:", data.data);
// 处理库位状态列表
data.data.storage_locations.forEach(location => {
console.log(`层${location.layer_name}: 占用=${location.is_occupied}, 锁定=${location.is_locked}`);
});
break;
case "storage_location_status_change":
console.log("库位状态变化:", data.layer_name, data.action, data.new_status);
break;
case "pong":
console.log("心跳响应:", data.timestamp);
break;
case "error":
console.error("服务器错误:", data.message);
break;
}
};
// 连接关闭
websocket.onclose = function(event) {
console.log("库位状态WebSocket连接已关闭");
};
// 连接错误
websocket.onerror = function(error) {
console.error("库位状态WebSocket连接错误:", error);
};
```
##### Python客户端示例
```python
import asyncio
import json
import websockets
from datetime import datetime
async def storage_location_websocket_client():
scene_id = "your-scene-id"
interval = 3
storage_area_id = "area-001"
uri = f"ws://localhost:8000/ws/storage-location/{scene_id}?interval={interval}&storage_area_id={storage_area_id}&is_occupied=false"
async with websockets.connect(uri) as websocket:
print("库位状态WebSocket连接已建立")
# 发送心跳包
await websocket.send(json.dumps({
"type": "ping",
"timestamp": datetime.now().isoformat()
}))
# 监听消息
async for message in websocket:
data = json.loads(message)
if data["type"] == "storage_location_update":
print(f"库位状态更新: 共{data['data']['total']}个库位")
for location in data["data"]["storage_locations"]:
print(f" 层{location['layer_name']}: 占用={location['is_occupied']}, 锁定={location['is_locked']}")
elif data["type"] == "storage_location_status_change":
print(f"库位状态变化: {data['layer_name']} {data['action']} {data['new_status']}")
elif data["type"] == "pong":
print(f"心跳响应: {data['timestamp']}")
elif data["type"] == "error":
print(f"服务器错误: {data['message']}")
# 运行客户端
asyncio.run(storage_location_websocket_client())
```
#### 特性说明
1. **智能推送**:服务器只在数据发生变化时才推送更新,避免不必要的网络流量
2. **灵活过滤**:支持多种过滤条件,可以精确筛选需要监控的库位
3. **心跳检测**:支持客户端主动发送心跳包,维持连接活跃状态
4. **错误处理**:完善的错误处理机制,连接异常时自动清理资源
5. **状态查询**:客户端可随时主动请求获取当前库位状态
6. **多客户端支持**:同一场景可支持多个客户端同时连接
### 4. 库位状态广播
#### 接口说明
建立WebSocket连接接收库位状态的广播消息。与实时推送接口的区别在于此接口主要用于被动接收广播不会主动定期推送。
#### 连接路径
```
ws://your-domain/ws/storage-location-broadcast/{scene_id}
```
#### 路径参数
| 参数名 | 类型 | 是否必须 | 描述 |
| --- | --- | --- | --- |
| scene_id | string | 是 | 场景ID |
#### 客户端消息格式
##### 心跳检测
```json
{
"type": "ping",
"timestamp": "2025-06-11T12:00:00.000Z"
}
```
#### 服务器消息格式
与库位状态实时推送接口相同,参见上述文档。
#### 使用场景
1. **监控面板**:多个监控客户端同时监听库位状态变化
2. **库位管理**:实时显示库位占用、锁定状态
3. **货物追踪**:监控货物存放和取出过程
4. **状态统计**:收集库位使用率和状态变化统计
## 错误码说明 ## 错误码说明
| 错误码 | 描述 | 解决方案 | | 错误码 | 描述 | 解决方案 |
@ -276,26 +607,33 @@ ws://your-domain/ws/task-execution-broadcast/{task_record_id}
### 3. 性能优化 ### 3. 性能优化
- 使用合适的推送间隔建议2-5秒 - 使用合适的推送间隔(任务执行结果建议2-5库位状态建议3-10秒)
- 客户端及时处理接收到的消息,避免消息积压 - 客户端及时处理接收到的消息,避免消息积压
- 对于不活跃的任务,考虑降低推送频率 - 对于不活跃的任务,考虑降低推送频率
- 库位状态推送时,合理使用过滤条件,避免获取过多不必要的数据
- 对于大规模库位监控,考虑按库区分组建立多个连接
### 4. 安全考虑 ### 4. 安全考虑
- 在生产环境中使用WSS协议WebSocket Secure - 在生产环境中使用WSS协议WebSocket Secure
- 实现适当的身份验证和授权机制 - 实现适当的身份验证和授权机制
- 限制连接数量,防止资源滥用 - 限制连接数量,防止资源滥用
- 对于库位状态推送,验证客户端是否有权限访问特定场景的库位数据
## 注意事项 ## 注意事项
1. **任务记录ID有效性**确保传入的任务记录ID存在且有效 1. **ID有效性**:确保传入的任务记录ID和场景ID存在且有效
2. **网络稳定性**WebSocket连接对网络质量要求较高不稳定的网络可能导致频繁断连 2. **网络稳定性**WebSocket连接对网络质量要求较高不稳定的网络可能导致频繁断连
3. **浏览器兼容性**确保目标浏览器支持WebSocket协议 3. **浏览器兼容性**确保目标浏览器支持WebSocket协议
4. **资源清理**页面关闭或组件销毁时及时关闭WebSocket连接 4. **资源清理**页面关闭或组件销毁时及时关闭WebSocket连接
5. **消息处理**合理处理接收到的消息避免阻塞UI线程 5. **消息处理**合理处理接收到的消息避免阻塞UI线程
6. **过滤条件**:库位状态推送时,合理设置过滤条件,避免获取过多数据影响性能
7. **数据更新频率**:库位状态数据更新频率可能较高,建议根据实际需求调整推送间隔
8. **并发连接**:避免对同一场景建立过多并发连接,建议复用连接或使用广播接口
## 更新日志 ## 更新日志
| 版本 | 日期 | 更新内容 | | 版本 | 日期 | 更新内容 |
| --- | --- | --- | | --- | --- | --- |
| 1.0.0 | 2025-06-11 | 初始版本,支持任务执行结果实时推送和广播功能 | | 1.0.0 | 2025-06-11 | 初始版本,支持任务执行结果实时推送和广播功能 |
| 1.1.0 | 2025-06-11 | 新增库位状态实时推送和广播功能,支持多种过滤条件和状态变化通知 |

Binary file not shown.

14
app.py
View File

@ -8,7 +8,6 @@ from contextlib import asynccontextmanager
from config.settings import settings from config.settings import settings
# 导入数据库相关 # 导入数据库相关
from data.session import init_database, close_database_connections, close_async_database_connections from data.session import init_database, close_database_connections, close_async_database_connections
# from data.cache import redis_client
# 导入路由注册函数 # 导入路由注册函数
from routes import register_routers from routes import register_routers
# 导入中间件注册函数 # 导入中间件注册函数
@ -27,11 +26,7 @@ async def lifespan(app: FastAPI):
""" """
# 启动前的初始化操作 # 启动前的初始化操作
# 初始化数据库 # 初始化数据库
init_database() init_database()
# 初始化Redis连接
# if redis_client.get_client() is None:
# logger.warning("Redis连接失败部分功能可能无法正常使用")
# 启动增强版任务调度器 # 启动增强版任务调度器
from services.enhanced_scheduler import scheduler from services.enhanced_scheduler import scheduler
await scheduler.start(worker_count=settings.TASK_SCHEDULER_MIN_WORKER_COUNT) await scheduler.start(worker_count=settings.TASK_SCHEDULER_MIN_WORKER_COUNT)
@ -58,22 +53,21 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
debug=settings.DEBUG debug=settings.DEBUG
) )
# 注册中间件 # 注册中间件
register_middlewares(app) register_middlewares(app)
# 注册所有路由 # 注册所有路由
register_routers(app) register_routers(app)
# 主函数 # 主函数
if __name__ == "__main__": if __name__ == "__main__":
# 从环境变量中获取端口默认为8000 # 从环境变量中获取端口默认为8000
import time
# start_time = time.time()
port = int(os.environ.get("PORT", settings.SERVER_PORT)) port = int(os.environ.get("PORT", settings.SERVER_PORT))
# 打印启动配置信息 # 打印启动配置信息
logger.info(f"服务器配置 - Host: 0.0.0.0, Port: {port}, Workers: {settings.SERVER_WORKERS}, Reload: {settings.SERVER_RELOAD}") logger.info(f"服务器配置 - Host: 0.0.0.0, Port: {port}, Workers: {settings.SERVER_WORKERS}, Reload: {settings.SERVER_RELOAD}")
end_time = time.time()
# 启动服务器 # 启动服务器
uvicorn.run( uvicorn.run(
"app:app", "app:app",

View File

@ -44,6 +44,7 @@ TF_API_TIMEOUT = int(os.getenv("TF_API_TIMEOUT", "60"))
TF_API_RETRY_TIMES = int(os.getenv("TF_API_RETRY_TIMES", "3")) TF_API_RETRY_TIMES = int(os.getenv("TF_API_RETRY_TIMES", "3"))
TF_API_MOCK_MODE = False TF_API_MOCK_MODE = False
TF_API_TOKEN_HEADER = os.getenv("TF_API_TOKEN_HEADER", "X-Access-Token") # token请求头名称 TF_API_TOKEN_HEADER = os.getenv("TF_API_TOKEN_HEADER", "X-Access-Token") # token请求头名称
TF_API_TOKEN = os.getenv("TF_API_TOKEN", "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NDk3NzY1MzEsInVzZXJuYW1lIjoiYWRtaW4ifQ.uRLHZuRQTrR2fHyA-dMzP46yXAa5wdjfdUcmr9PNY4g")
def get_tf_api_config() -> Dict[str, Any]: def get_tf_api_config() -> Dict[str, Any]:
"""获取天风系统API配置""" """获取天风系统API配置"""

File diff suppressed because it is too large Load Diff

View File

@ -6,9 +6,9 @@
提供呼叫器设备相关的API接口 提供呼叫器设备相关的API接口
""" """
from typing import Dict, List, Any, Optional # # from typing import Dict, List, Any, Optional
from fastapi import APIRouter, Body, Query, Path, Request, File, UploadFile, Form, Response from fastapi import APIRouter, Body, Query, Path, Request, File, UploadFile, Form, Response
from pydantic import BaseModel # # from pydantic import BaseModel
from routes.common_api import format_response, error_response from routes.common_api import format_response, error_response
from utils.logger import get_logger from utils.logger import get_logger

View File

@ -13,6 +13,9 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Path, Query
from datetime import datetime, timedelta from datetime import datetime, timedelta
from services.task_record_service import TaskRecordService from services.task_record_service import TaskRecordService
from services.operate_point_service import OperatePointService
from data.session import get_db
from routes.model.operate_point_model import StorageLocationListRequest
from utils.logger import get_logger from utils.logger import get_logger
# 创建路由 # 创建路由
@ -39,8 +42,12 @@ class ConnectionManager:
def __init__(self): def __init__(self):
# 存储WebSocket连接按task_record_id分组 # 存储WebSocket连接按task_record_id分组
self.active_connections: Dict[str, Set[WebSocket]] = {} self.active_connections: Dict[str, Set[WebSocket]] = {}
# 存储库位状态连接按scene_id分组
self.storage_location_connections: Dict[str, Set[WebSocket]] = {}
# 存储连接的最后推送时间 # 存储连接的最后推送时间
self.last_push_time: Dict[str, datetime] = {} self.last_push_time: Dict[str, datetime] = {}
# 存储库位状态的最后推送时间
self.storage_location_last_push_time: Dict[str, datetime] = {}
async def connect(self, websocket: WebSocket, task_record_id: str): async def connect(self, websocket: WebSocket, task_record_id: str):
"""连接WebSocket""" """连接WebSocket"""
@ -50,6 +57,14 @@ class ConnectionManager:
self.active_connections[task_record_id].add(websocket) self.active_connections[task_record_id].add(websocket)
logger.info(f"WebSocket连接已建立任务记录ID: {task_record_id}. 当前连接数: {len(self.active_connections[task_record_id])}") logger.info(f"WebSocket连接已建立任务记录ID: {task_record_id}. 当前连接数: {len(self.active_connections[task_record_id])}")
async def connect_storage_location(self, websocket: WebSocket, scene_id: str):
"""连接库位状态WebSocket"""
await websocket.accept()
if scene_id not in self.storage_location_connections:
self.storage_location_connections[scene_id] = set()
self.storage_location_connections[scene_id].add(websocket)
logger.info(f"库位状态WebSocket连接已建立场景ID: {scene_id}. 当前连接数: {len(self.storage_location_connections[scene_id])}")
def disconnect(self, websocket: WebSocket, task_record_id: str): def disconnect(self, websocket: WebSocket, task_record_id: str):
"""断开WebSocket连接""" """断开WebSocket连接"""
if task_record_id in self.active_connections: if task_record_id in self.active_connections:
@ -60,6 +75,16 @@ class ConnectionManager:
self.last_push_time.pop(task_record_id, None) self.last_push_time.pop(task_record_id, None)
logger.info(f"WebSocket连接已断开任务记录ID: {task_record_id}") logger.info(f"WebSocket连接已断开任务记录ID: {task_record_id}")
def disconnect_storage_location(self, websocket: WebSocket, scene_id: str):
"""断开库位状态WebSocket连接"""
if scene_id in self.storage_location_connections:
self.storage_location_connections[scene_id].discard(websocket)
if not self.storage_location_connections[scene_id]:
# 如果没有连接了,清理数据
del self.storage_location_connections[scene_id]
self.storage_location_last_push_time.pop(scene_id, None)
logger.info(f"库位状态WebSocket连接已断开场景ID: {scene_id}")
async def send_personal_message(self, message: str, websocket: WebSocket): async def send_personal_message(self, message: str, websocket: WebSocket):
"""发送个人消息""" """发送个人消息"""
try: try:
@ -83,6 +108,23 @@ class ConnectionManager:
# 清理断开的连接 # 清理断开的连接
for websocket in disconnected_websockets: for websocket in disconnected_websockets:
self.disconnect(websocket, task_record_id) self.disconnect(websocket, task_record_id)
async def broadcast_to_storage_location(self, message: str, scene_id: str):
"""向特定场景的所有库位状态连接广播消息"""
if scene_id not in self.storage_location_connections:
return
disconnected_websockets = []
for websocket in self.storage_location_connections[scene_id].copy():
try:
await websocket.send_text(message)
except Exception as e:
logger.error(f"广播库位状态消息失败: {str(e)}")
disconnected_websockets.append(websocket)
# 清理断开的连接
for websocket in disconnected_websockets:
self.disconnect_storage_location(websocket, scene_id)
# 连接管理器实例 # 连接管理器实例
manager = ConnectionManager() manager = ConnectionManager()
@ -146,6 +188,127 @@ async def websocket_task_execution(
finally: finally:
manager.disconnect(websocket, task_record_id) manager.disconnect(websocket, task_record_id)
@router.websocket("/storage-location/{scene_id}")
async def websocket_storage_location_status(
websocket: WebSocket,
scene_id: str = Path(..., description="场景ID"),
interval: int = Query(default=3, description="推送间隔(秒)", ge=1, le=30),
storage_area_id: Optional[str] = Query(None, description="库区ID用于过滤特定库区"),
station_name: Optional[str] = Query(None, description="站点名称,用于过滤特定站点"),
layer_name: Optional[str] = Query(None, description="层名称,用于过滤特定层"),
is_occupied: Optional[bool] = Query(None, description="是否占用过滤"),
is_locked: Optional[bool] = Query(None, description="是否锁定过滤"),
is_disabled: Optional[bool] = Query(None, description="是否禁用过滤")
):
"""
库位状态实时推送WebSocket连接
Args:
websocket: WebSocket连接对象
scene_id: 场景ID
interval: 推送间隔默认3秒范围1-30
storage_area_id: 库区ID用于过滤特定库区
station_name: 站点名称用于过滤特定站点
layer_name: 层名称用于过滤特定层
is_occupied: 是否占用过滤
is_locked: 是否锁定过滤
is_disabled: 是否禁用过滤
"""
await manager.connect_storage_location(websocket, scene_id)
# 构建过滤条件
filter_params = {
"scene_id": scene_id,
"storage_area_id": storage_area_id,
"station_name": station_name,
"layer_name": layer_name,
"is_occupied": is_occupied,
"is_locked": is_locked,
"is_disabled": is_disabled
}
try:
# 立即发送一次当前状态
await send_storage_location_status(scene_id, websocket, filter_params)
# 启动定时推送任务
push_task = asyncio.create_task(
periodic_push_storage_location_status(websocket, scene_id, interval, filter_params)
)
try:
# 监听客户端消息
while True:
# 接收客户端消息
data = await websocket.receive_text()
try:
message = json.loads(data)
await handle_storage_location_websocket_message(websocket, scene_id, message, filter_params)
except json.JSONDecodeError:
await websocket.send_text(safe_json_dumps({
"type": "error",
"message": "无效的JSON格式"
}, ensure_ascii=False))
except Exception as e:
logger.error(f"处理库位状态WebSocket消息失败: {str(e)}")
await websocket.send_text(safe_json_dumps({
"type": "error",
"message": f"处理消息失败: {str(e)}"
}, ensure_ascii=False))
finally:
# 取消定时推送任务
push_task.cancel()
try:
await push_task
except asyncio.CancelledError:
pass
except WebSocketDisconnect:
logger.info(f"库位状态WebSocket客户端断开连接场景ID: {scene_id}")
except Exception as e:
logger.error(f"库位状态WebSocket连接异常: {str(e)}")
finally:
manager.disconnect_storage_location(websocket, scene_id)
@router.websocket("/storage-location-broadcast/{scene_id}")
async def websocket_storage_location_broadcast(
websocket: WebSocket,
scene_id: str = Path(..., description="场景ID")
):
"""
库位状态广播WebSocket连接只接收广播不主动推送
Args:
websocket: WebSocket连接对象
scene_id: 场景ID
"""
await manager.connect_storage_location(websocket, scene_id)
try:
# 发送初始状态
await send_storage_location_status(scene_id, websocket, {"scene_id": scene_id})
# 等待连接断开或消息
while True:
try:
data = await websocket.receive_text()
# 可以处理客户端的心跳或其他控制消息
try:
message = json.loads(data)
if message.get("type") == "ping":
await websocket.send_text(safe_json_dumps({
"type": "pong",
"timestamp": datetime.now().isoformat()
}, ensure_ascii=False))
except:
pass
except WebSocketDisconnect:
break
except Exception as e:
logger.error(f"库位状态广播WebSocket连接异常: {str(e)}")
finally:
manager.disconnect_storage_location(websocket, scene_id)
async def handle_websocket_message(websocket: WebSocket, task_record_id: str, message: Dict[str, Any]): async def handle_websocket_message(websocket: WebSocket, task_record_id: str, message: Dict[str, Any]):
""" """
处理WebSocket客户端消息 处理WebSocket客户端消息
@ -268,6 +431,149 @@ async def periodic_push_task_status(websocket: WebSocket, task_record_id: str, i
except Exception as e: except Exception as e:
logger.error(f"定期推送任务状态失败: {str(e)}") logger.error(f"定期推送任务状态失败: {str(e)}")
async def send_storage_location_status(scene_id: str, websocket: WebSocket, filter_params: Dict[str, Any]):
"""
发送库位状态
Args:
scene_id: 场景ID
websocket: WebSocket连接对象
filter_params: 过滤参数
"""
try:
# 获取库位状态
with get_db() as db:
# 构建请求参数过滤掉None值
request_params = {k: v for k, v in filter_params.items() if v is not None}
# 设置默认分页参数
request_params.setdefault("page", 1)
request_params.setdefault("page_size", 1000) # 默认获取大量数据
request = StorageLocationListRequest(**request_params)
result = OperatePointService.get_storage_location_list(db, request)
response_data = {
"type": "storage_location_update",
"scene_id": scene_id,
"timestamp": datetime.now().isoformat(),
"data": {
"total": result.total,
"page": result.page,
"page_size": result.page_size,
"total_pages": result.total_pages,
"storage_locations": [location.dict() for location in result.storage_locations]
},
"message": "成功获取库位状态"
}
await websocket.send_text(safe_json_dumps(response_data, ensure_ascii=False))
manager.storage_location_last_push_time[scene_id] = datetime.now()
logger.debug(f"已发送库位状态场景ID: {scene_id}")
except Exception as e:
logger.error(f"发送库位状态失败: {str(e)}")
try:
await websocket.send_text(safe_json_dumps({
"type": "error",
"scene_id": scene_id,
"message": f"获取库位状态失败: {str(e)}",
"timestamp": datetime.now().isoformat()
}, ensure_ascii=False))
except:
# 如果连接已断开,忽略错误
pass
async def periodic_push_storage_location_status(websocket: WebSocket, scene_id: str, interval: int, filter_params: Dict[str, Any]):
"""
定期推送库位状态
Args:
websocket: WebSocket连接对象
scene_id: 场景ID
interval: 推送间隔
filter_params: 过滤参数
"""
logger.info(f"开始定期推送库位状态场景ID: {scene_id}, 间隔: {interval}")
last_data_hash = None # 用于检测数据是否发生变化
try:
while True:
await asyncio.sleep(interval)
# 获取当前数据
try:
with get_db() as db:
# 构建请求参数过滤掉None值
request_params = {k: v for k, v in filter_params.items() if v is not None}
request_params.setdefault("page", 1)
request_params.setdefault("page_size", 1000)
request = StorageLocationListRequest(**request_params)
result = OperatePointService.get_storage_location_list(db, request)
# 计算数据哈希,只有数据变化时才推送
import hashlib
current_data = safe_json_dumps(
[location.dict() for location in result.storage_locations],
sort_keys=True, ensure_ascii=False
)
current_hash = hashlib.md5(current_data.encode()).hexdigest()
if current_hash != last_data_hash:
await send_storage_location_status(scene_id, websocket, filter_params)
last_data_hash = current_hash
logger.debug(f"库位状态已更新并推送场景ID: {scene_id}")
else:
logger.debug(f"库位状态无变化跳过推送场景ID: {scene_id}")
except Exception as e:
logger.error(f"获取库位状态失败: {str(e)}")
# 发送错误状态
try:
await websocket.send_text(safe_json_dumps({
"type": "error",
"scene_id": scene_id,
"message": f"获取库位状态失败: {str(e)}",
"timestamp": datetime.now().isoformat()
}, ensure_ascii=False))
except:
# 连接可能已断开
break
except asyncio.CancelledError:
logger.info(f"定期推送库位状态已取消场景ID: {scene_id}")
raise
except Exception as e:
logger.error(f"定期推送库位状态失败: {str(e)}")
async def handle_storage_location_websocket_message(websocket: WebSocket, scene_id: str, message: Dict[str, Any], filter_params: Dict[str, Any]):
"""
处理库位状态WebSocket客户端消息
Args:
websocket: WebSocket连接对象
scene_id: 场景ID
message: 客户端消息
filter_params: 过滤参数
"""
message_type = message.get("type", "")
if message_type == "get_status":
# 获取当前状态
await send_storage_location_status(scene_id, websocket, filter_params)
elif message_type == "ping":
# 心跳检测
await websocket.send_text(safe_json_dumps({
"type": "pong",
"timestamp": datetime.now().isoformat()
}, ensure_ascii=False))
else:
await websocket.send_text(safe_json_dumps({
"type": "error",
"message": f"不支持的消息类型: {message_type}"
}, ensure_ascii=False))
@router.websocket("/task-execution-broadcast/{task_record_id}") @router.websocket("/task-execution-broadcast/{task_record_id}")
async def websocket_task_execution_broadcast( async def websocket_task_execution_broadcast(
websocket: WebSocket, websocket: WebSocket,
@ -327,4 +633,50 @@ async def broadcast_task_update(task_record_id: str, data: Dict[str, Any]):
}, ensure_ascii=False) }, ensure_ascii=False)
await manager.broadcast_to_task(message, task_record_id) await manager.broadcast_to_task(message, task_record_id)
logger.info(f"已广播任务更新消息任务记录ID: {task_record_id}") logger.info(f"已广播任务更新消息任务记录ID: {task_record_id}")
async def broadcast_storage_location_update(scene_id: str, data: Dict[str, Any]):
"""
广播库位状态更新消息给所有相关连接
Args:
scene_id: 场景ID
data: 要广播的数据
"""
if scene_id not in manager.storage_location_connections:
return
message = safe_json_dumps({
"type": "storage_location_update",
"scene_id": scene_id,
"timestamp": datetime.now().isoformat(),
"data": data
}, ensure_ascii=False)
await manager.broadcast_to_storage_location(message, scene_id)
logger.info(f"已广播库位状态更新消息场景ID: {scene_id}")
async def broadcast_storage_location_status_change(scene_id: str, layer_name: str, action: str, new_status: Dict[str, Any]):
"""
广播库位状态变化消息给所有相关连接
Args:
scene_id: 场景ID
layer_name: 层名称
action: 操作类型
new_status: 新状态
"""
if scene_id not in manager.storage_location_connections:
return
message = safe_json_dumps({
"type": "storage_location_status_change",
"scene_id": scene_id,
"layer_name": layer_name,
"action": action,
"timestamp": datetime.now().isoformat(),
"new_status": new_status
}, ensure_ascii=False)
await manager.broadcast_to_storage_location(message, scene_id)
logger.info(f"已广播库位状态变化消息场景ID: {scene_id},层名称: {layer_name},操作: {action}")

View File

@ -192,6 +192,64 @@ class RobotBlockHandler(BlockHandler):
async def _call_external_api(self, api_name: str, params: Dict[str, Any]) -> Dict[str, Any]: async def _call_external_api(self, api_name: str, params: Dict[str, Any]) -> Dict[str, Any]:
"""调用外部API的通用方法""" """调用外部API的通用方法"""
return await call_robot_api(api_name, params) return await call_robot_api(api_name, params)
async def _validate_and_convert_key_route(self, key_route: str, map_id: str) -> tuple[bool, str, str]:
"""
校验并转换keyRoute参数
Args:
key_route: 传入的关键路径可能是动作点名称或库位名称
map_id: 地图ID用于校验场景ID
Returns:
tuple: (是否成功, 最终的station_name, 错误消息)
"""
try:
from sqlalchemy.ext.asyncio import AsyncSession
from data.session import get_async_session
from data.models.operate_point import OperatePoint
from data.models.operate_point_layer import OperatePointLayer
from sqlalchemy import select
async with get_async_session() as session:
session: AsyncSession = session
# 首先检查是否是动作点operate_point表的station_name字段
stmt = select(OperatePoint).where(
OperatePoint.station_name == key_route,
OperatePoint.is_disabled == False,
OperatePoint.scene_id == map_id
)
result = await session.execute(stmt)
operate_point = result.scalar_one_or_none()
if operate_point:
logger.info(f"keyRoute '{key_route}' 识别为动作点场景ID: {map_id}")
return True, key_route, ""
# 如果不是动作点检查是否是库位operate_point_layer表的layer_name字段
stmt = select(OperatePointLayer).where(
OperatePointLayer.layer_name == key_route,
OperatePointLayer.is_disabled == False,
OperatePointLayer.scene_id == map_id
)
result = await session.execute(stmt)
operate_point_layer = result.scalar_one_or_none()
if operate_point_layer:
station_name = operate_point_layer.station_name
logger.info(f"keyRoute '{key_route}' 识别为库位,对应的动作点: {station_name}场景ID: {map_id}")
return True, station_name, ""
# 都不匹配,返回错误
error_msg = f"keyRoute '{key_route}' 在场景 {map_id} 中既不是有效的动作点名称也不是有效的库位名称"
logger.error(error_msg)
return False, "", error_msg
except Exception as e:
error_msg = f"校验keyRoute时发生异常: {str(e)}"
logger.error(error_msg)
return False, "", error_msg
def _analyze_affected_blocks(self, block: Dict[str, Any], current_block_id: str, current_block_name: str) -> List[Dict[str, Any]]: def _analyze_affected_blocks(self, block: Dict[str, Any], current_block_id: str, current_block_name: str) -> List[Dict[str, Any]]:
""" """
@ -397,6 +455,8 @@ class AgvOperationBlockHandler(RobotBlockHandler):
# 获取关键参数用于验证 # 获取关键参数用于验证
target_site_label = input_params.get("targetSiteLabel") target_site_label = input_params.get("targetSiteLabel")
script_name = input_params.get("task") script_name = input_params.get("task")
map_id = context.map_id
# 参数检查 # 参数检查
if not target_site_label: if not target_site_label:
result = { result = {
@ -406,6 +466,18 @@ class AgvOperationBlockHandler(RobotBlockHandler):
await self._record_task_log(block, result, context) await self._record_task_log(block, result, context)
return result return result
# 校验并转换target_site_label参数
# print(f"AgvOperation input_params >>>>>>>>>>>>>>>>>>>>>>>>>>>> target_site_label: {target_site_label}, map_id: {map_id}")
is_valid, validated_station_name, error_msg = await self._validate_and_convert_key_route(target_site_label, map_id)
# print(f"AgvOperation output_params >>>>>>>>>>>>>>>>>>>>>>>>>>>> validated_station_name: {validated_station_name}, error_msg: {error_msg}")
if not is_valid:
result = {
"success": False,
"message": error_msg
}
await self._record_task_log(block, result, context)
return result
# 获取当前块信息 # 获取当前块信息
current_block_id = block.get("id", "unknown") current_block_id = block.get("id", "unknown")
current_block_name = block.get("name", f"b{current_block_id}") current_block_name = block.get("name", f"b{current_block_id}")
@ -418,9 +490,9 @@ class AgvOperationBlockHandler(RobotBlockHandler):
# 记录使用的机器人ID # 记录使用的机器人ID
if vehicle: if vehicle:
logger.info(f"执行机器人通用动作,块 {current_block_name}(ID:{current_block_id}) 使用机器人: {vehicle}, 目标站点: {target_site_label}") logger.info(f"执行机器人通用动作,块 {current_block_name}(ID:{current_block_id}) 使用机器人: {vehicle}, 目标站点: {validated_station_name} (原始输入: {target_site_label})")
else: else:
error_msg = f"执行机器人通用动作失败未指定机器人ID目标站点: {target_site_label}" error_msg = f"执行机器人通用动作失败未指定机器人ID目标站点: {validated_station_name}"
logger.error(error_msg) logger.error(error_msg)
result = { result = {
"success": False, "success": False,
@ -432,7 +504,7 @@ class AgvOperationBlockHandler(RobotBlockHandler):
from services.sync_service import add_action from services.sync_service import add_action
result = await add_action( result = await add_action(
task_id=agv_task_id, task_id=agv_task_id,
station_name=target_site_label, station_name=validated_station_name, # 使用校验后的站点名称
action=script_name, action=script_name,
token=context.token token=context.token
) )
@ -442,17 +514,17 @@ class AgvOperationBlockHandler(RobotBlockHandler):
task_id = result.get("result", {}).get("id", "") task_id = result.get("result", {}).get("id", "")
task_block_result = await wait_for_task_block_action_completion(task_id, context.token, context) task_block_result = await wait_for_task_block_action_completion(task_id, context.token, context)
if task_block_result.get("is_canceled", False): if task_block_result.get("is_canceled", False):
return {"success": True, "message": f"机器人通用动作取消,目标站点: {target_site_label}", "is_canceled": True} return {"success": True, "message": f"机器人通用动作取消,目标站点: {validated_station_name} 执行动作: {script_name}", "is_canceled": True}
# return result # return result
if task_block_result.get("success", False): if task_block_result.get("success", False):
task_block_status = task_block_result.get("result", {}).get("status", "") task_block_status = task_block_result.get("result", {}).get("status", "")
if task_block_status == 3: if task_block_status == 3:
result["message"] = f"机器人通用动作成功,目标站点: {target_site_label}" result["message"] = f"机器人通用动作成功,目标站点: {validated_station_name} 执行动作: {script_name}"
elif task_block_status == 4: elif task_block_status == 4:
result["message"] = f"机器人通用动作失败,目标站点: {target_site_label}:{task_block_result.get('message', '')}" result["message"] = f"机器人通用动作失败,目标站点: {validated_station_name} 执行动作: {script_name}:{task_block_result.get('message', '')}"
result["success"] = False result["success"] = False
elif task_block_status == 5: elif task_block_status == 5:
result["message"] = f"机器人通用动作终止,目标站点: {target_site_label}" result["message"] = f"机器人通用动作终止,目标站点: {validated_station_name} 执行动作: {script_name}"
else: else:
result["message"] = f"机器人通用动作失败: {result.get('message', '未知错误')}" result["message"] = f"机器人通用动作失败: {result.get('message', '未知错误')}"
@ -650,6 +722,7 @@ class SelectAgvBlockHandler(RobotBlockHandler):
priority = input_params.get("priority", 1) priority = input_params.get("priority", 1)
amr_name = input_params.get("vehicle", "") amr_name = input_params.get("vehicle", "")
amr_group_name = input_params.get("group", "") amr_group_name = input_params.get("group", "")
map_id = context.map_id
# 确保priority是整数类型默认为1 # 确保priority是整数类型默认为1
try: try:
@ -670,12 +743,23 @@ class SelectAgvBlockHandler(RobotBlockHandler):
await self._record_task_log(block, result, context) await self._record_task_log(block, result, context)
return result return result
# 校验并转换keyRoute参数
# print(f"input_params >>>>>>>>>>>>>>>>>>>>>>>>>>>> key_route: {key_route}, map_id: {map_id}")
is_valid, key_station_name, error_msg = await self._validate_and_convert_key_route(key_route, map_id)
# print(f"output_params >>>>>>>>>>>>>>>>>>>>>>>>>>>> key_station_name: {key_station_name}, error_msg: {error_msg}")
if not is_valid:
result = {
"success": False,
"message": error_msg
}
await self._record_task_log(block, result, context)
return result
# 调用外部API选择执行机器人 # 调用外部API选择执行机器人
# result = await self._call_external_api("select_agv", input_params)
from services.sync_service import create_choose_amr_task, wait_for_amr_selection from services.sync_service import create_choose_amr_task, wait_for_amr_selection
result = await create_choose_amr_task( result = await create_choose_amr_task(
task_id=context.task_record_id, task_id=context.task_record_id,
key_station_name=key_route, key_station_name=key_station_name, # 使用校验后的station_name
amr_name=amr_name, amr_name=amr_name,
amr_group_name=amr_group_name, amr_group_name=amr_group_name,
token=context.token, token=context.token,

View File

@ -20,7 +20,7 @@ from data.models.taskrecord import VWEDTaskRecord
from data.session import get_async_session from data.session import get_async_session
from utils.component_manager import component_manager from utils.component_manager import component_manager
from data.enum.task_def_enum import EnableStatus, PeriodicTaskStatus, TaskStatusEnum from data.enum.task_def_enum import EnableStatus, PeriodicTaskStatus, TaskStatusEnum
from data.enum.task_record_enum import TaskStatus # from data.enum.task_record_enum import TaskStatus
from utils.logger import get_logger from utils.logger import get_logger
# 获取日志记录器 # 获取日志记录器

View File

@ -17,10 +17,11 @@ from concurrent.futures import ThreadPoolExecutor
import requests import requests
import json import json
from datetime import datetime from datetime import datetime
from config.tf_api_config import TF_API_TOKEN
from config.settings import settings from config.settings import settings
class AlertSyncService: class AlertSyncService:
""" """
告警同步服务类 告警同步服务类
@ -195,7 +196,7 @@ class AlertSyncService:
json=alert_data, json=alert_data,
timeout=self.timeout, timeout=self.timeout,
headers={'Content-Type': 'application/json', headers={'Content-Type': 'application/json',
'x-access-token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NDk3NzY1MzEsInVzZXJuYW1lIjoiYWRtaW4ifQ.uRLHZuRQTrR2fHyA-dMzP46yXAa5wdjfdUcmr9PNY4g' 'x-access-token': TF_API_TOKEN
} }
) )
if response.status_code == 200: if response.status_code == 200: