703 lines
24 KiB
Python
703 lines
24 KiB
Python
"""
|
||
工作手机SDK v3.0 - 设备管理路由
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from fastapi import APIRouter, HTTPException, Depends
|
||
from fastapi.responses import JSONResponse
|
||
from typing import List, Optional
|
||
from pydantic import BaseModel, Field
|
||
import uuid
|
||
|
||
from services.ws_hub import ws_hub
|
||
from services.device_manager import device_manager
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
# ========== 数据模型 ==========
|
||
|
||
class DeviceResponse(BaseModel):
|
||
"""设备信息响应"""
|
||
device_id: str
|
||
device_id_md5: Optional[str] = None
|
||
name: Optional[str] = None
|
||
model: Optional[str] = None
|
||
status: str = "offline"
|
||
android_version: Optional[str] = None
|
||
agent_version: Optional[str] = None
|
||
capabilities: List[str] = []
|
||
apps: List[str] = []
|
||
last_heartbeat: Optional[str] = None
|
||
|
||
|
||
class ScreenshotResponse(BaseModel):
|
||
"""截图响应"""
|
||
image_url: Optional[str] = None
|
||
base64: Optional[str] = None
|
||
width: int
|
||
height: int
|
||
|
||
|
||
class ClickRequest(BaseModel):
|
||
"""点击请求"""
|
||
x: int
|
||
y: int
|
||
|
||
|
||
class ClickTextRequest(BaseModel):
|
||
"""点击文字请求"""
|
||
text: str
|
||
timeout: int = 10
|
||
|
||
|
||
class InputRequest(BaseModel):
|
||
"""输入请求"""
|
||
text: str
|
||
clear: bool = True
|
||
|
||
|
||
class SwipeRequest(BaseModel):
|
||
"""滑动请求。duration 已弃用,设备 Agent 仅支持 direction + scale。"""
|
||
direction: str # up, down, left, right
|
||
scale: float = 0.8
|
||
duration: Optional[float] = Field(
|
||
default=None,
|
||
description="弃用字段:当前 Agent 未实现持续时长控制;传入时返回 duration_unsupported。",
|
||
)
|
||
|
||
|
||
class DeviceOperationReceipt(BaseModel):
|
||
"""设备 Agent 统一执行/离线回执。"""
|
||
code: int
|
||
success: bool
|
||
data: dict = Field(default_factory=dict)
|
||
error_code: Optional[str] = None
|
||
error_message: Optional[str] = None
|
||
retryable: bool = False
|
||
trace_id: Optional[str] = None
|
||
channel_used: str
|
||
raw_rpc_receipt: Optional[dict] = None
|
||
readback: Optional[dict] = None
|
||
|
||
|
||
class DeviceNotFoundReceipt(BaseModel):
|
||
"""设备详情未找到时的统一结构化回执。"""
|
||
code: int = 404
|
||
success: bool = False
|
||
data: dict = Field(default_factory=dict)
|
||
error_code: str = "device_not_found"
|
||
error_message: str
|
||
retryable: bool = False
|
||
trace_id: str
|
||
channel_used: str = "websocket/registry"
|
||
raw_rpc_receipt: dict
|
||
readback: Optional[dict] = None
|
||
|
||
|
||
DEVICE_NOT_FOUND_EXAMPLE = {
|
||
"code": 404,
|
||
"success": False,
|
||
"data": {"device_id": "device-unknown"},
|
||
"error_code": "device_not_found",
|
||
"error_message": "设备未在登记信息或 WSS Agent 中找到",
|
||
"retryable": False,
|
||
"trace_id": "device-detail-trace-id",
|
||
"channel_used": "websocket/registry",
|
||
"raw_rpc_receipt": {
|
||
"operation": "get_device",
|
||
"device_id": "device-unknown",
|
||
"found": False,
|
||
"source": "registry+wss",
|
||
},
|
||
"readback": None,
|
||
}
|
||
|
||
|
||
class HeartbeatConfigRequest(BaseModel):
|
||
"""心跳配置请求"""
|
||
heartbeat_interval_seconds: int
|
||
|
||
|
||
class AITaskRequest(BaseModel):
|
||
"""AI 任务推送请求"""
|
||
instruction: str
|
||
priority: int = 5
|
||
|
||
|
||
class StandingOrderRequest(BaseModel):
|
||
"""常驻指令推送请求"""
|
||
order: str
|
||
|
||
|
||
def _ws_receipt(result: dict, *, device_id: str = "", action: str = "") -> dict:
|
||
"""统一设备域 WS 原始回执,供存客宝逐条真机验收追踪。"""
|
||
raw = dict(result or {})
|
||
data = raw.get("data") or {}
|
||
code = int(raw.get("code", 500))
|
||
data = raw.get("data") if isinstance(raw.get("data"), dict) else {}
|
||
success = bool(data.get("success")) if "success" in data else code == 200
|
||
return {
|
||
"code": code,
|
||
"success": success,
|
||
"data": data,
|
||
"channel_used": "websocket/agent",
|
||
"trace_id": raw.get("trace_id") or raw.get("command_id"),
|
||
"raw_rpc_receipt": raw,
|
||
"readback": data.get("readback") or data.get("db_readback"),
|
||
}
|
||
|
||
|
||
def _ws_offline_receipt(device_id: str, action: str, reason: str = "device_offline") -> JSONResponse:
|
||
"""WSS Agent 不在线时返回可审计的结构化 503,不转主机 ADB。"""
|
||
trace_id = uuid.uuid4().hex
|
||
receipt = {
|
||
"operation": action,
|
||
"device_id": device_id,
|
||
"channel": "websocket/offline",
|
||
"reason": reason,
|
||
"trace_id": trace_id,
|
||
}
|
||
return JSONResponse(
|
||
status_code=503,
|
||
content={
|
||
"code": 503,
|
||
"success": False,
|
||
"data": {},
|
||
"error_code": "device_offline",
|
||
"error_message": "设备 WSS Agent 未在线",
|
||
"retryable": True,
|
||
"trace_id": trace_id,
|
||
"channel_used": "websocket/offline",
|
||
"raw_rpc_receipt": receipt,
|
||
"readback": None,
|
||
},
|
||
)
|
||
|
||
|
||
def _ws_failure_receipt(device_id: str, action: str, result: dict) -> JSONResponse:
|
||
"""Agent 非成功回执保持原始信封并以 503 暴露。"""
|
||
body = _ws_receipt(result, device_id=device_id, action=action)
|
||
body.update({
|
||
"code": 503,
|
||
"success": False,
|
||
"error_code": body.get("data", {}).get("error_code") or "device_command_failed",
|
||
"error_message": body.get("data", {}).get("error_message") or result.get("message") or "设备 Agent 执行失败",
|
||
"retryable": True,
|
||
})
|
||
return JSONResponse(status_code=503, content=body)
|
||
|
||
|
||
async def list_ws_managed_devices() -> List[dict]:
|
||
"""设备/Fleet 仅使用登记数据与 WSS Agent 状态,不扫描主机 ADB。"""
|
||
stored: List[dict] = []
|
||
try:
|
||
stored = await device_manager.get_all_devices()
|
||
except Exception:
|
||
pass
|
||
online = {item["device_id"]: item for item in ws_hub.get_online_devices()}
|
||
merged = {item.get("device_id"): dict(item) for item in stored if item.get("device_id")}
|
||
for device_id, info in online.items():
|
||
merged[device_id] = {**merged.get(device_id, {}), **info}
|
||
from services.device_id_util import enrich_device_id_fields, sanitize_sensitive_fields
|
||
devices = []
|
||
for device_id, item in merged.items():
|
||
device = dict(item)
|
||
is_online = device_id in online
|
||
device["device_id"] = device_id
|
||
device["status"] = "online" if is_online else "offline"
|
||
device["connection_type"] = "websocket" if is_online else "offline"
|
||
devices.append(enrich_device_id_fields(device))
|
||
return sanitize_sensitive_fields(devices)
|
||
|
||
|
||
# ========== 设备列表 ==========
|
||
|
||
@router.get("/devices", response_model=dict)
|
||
async def get_devices():
|
||
"""获取所有登记设备(WSS Agent 状态为唯一在线依据)。"""
|
||
devices = await list_ws_managed_devices()
|
||
return {"code": 200, "data": devices}
|
||
|
||
|
||
@router.get(
|
||
"/devices/{device_id}",
|
||
response_model=dict,
|
||
responses={
|
||
404: {
|
||
"model": DeviceNotFoundReceipt,
|
||
"description": "设备未在登记信息或 WSS Agent 中找到",
|
||
"content": {"application/json": {"example": DEVICE_NOT_FOUND_EXAMPLE}},
|
||
},
|
||
},
|
||
)
|
||
async def get_device(device_id: str):
|
||
"""获取设备详情(登记信息 + WSS Agent,无主机 ADB 探测)。"""
|
||
|
||
# 检查 WebSocket 在线
|
||
online_info = ws_hub.get_device_info(device_id)
|
||
|
||
# 检查数据库(容错)
|
||
db_info = None
|
||
try:
|
||
db_info = await device_manager.get_device(device_id)
|
||
except Exception:
|
||
pass
|
||
|
||
if not online_info and not db_info:
|
||
trace_id = uuid.uuid4().hex
|
||
receipt = {
|
||
"operation": "get_device",
|
||
"device_id": device_id,
|
||
"found": False,
|
||
"source": "registry+wss",
|
||
"trace_id": trace_id,
|
||
}
|
||
return JSONResponse(
|
||
status_code=404,
|
||
content={
|
||
"code": 404,
|
||
"success": False,
|
||
"data": {"device_id": device_id},
|
||
"error_code": "device_not_found",
|
||
"error_message": "设备未在登记信息或 WSS Agent 中找到",
|
||
"retryable": False,
|
||
"trace_id": trace_id,
|
||
"channel_used": "websocket/registry",
|
||
"raw_rpc_receipt": receipt,
|
||
"readback": None,
|
||
},
|
||
)
|
||
|
||
device = {**(db_info or {}), **(online_info or {})}
|
||
if online_info:
|
||
device["status"] = "online"
|
||
device["connection_type"] = "websocket"
|
||
else:
|
||
device["status"] = "offline"
|
||
device["connection_type"] = "offline"
|
||
device["device_id"] = device_id
|
||
from services.device_id_util import enrich_device_id_fields, sanitize_sensitive_fields
|
||
enrich_device_id_fields(device)
|
||
|
||
return {"code": 200, "data": sanitize_sensitive_fields(device)}
|
||
|
||
|
||
@router.delete("/devices/{device_id}", response_model=dict)
|
||
async def delete_offline_device(device_id: str):
|
||
"""删除失效登记设备;在线 WSS 设备必须先断开,避免误删活跃连接。"""
|
||
if ws_hub.is_online(device_id):
|
||
raise HTTPException(status_code=409, detail="设备在线,停止连接后再删除")
|
||
existing = await device_manager.get_device(device_id)
|
||
if not existing:
|
||
raise HTTPException(status_code=404, detail="设备不存在或已删除")
|
||
result = await device_manager.delete_device(device_id)
|
||
if not result.get("deleted"):
|
||
raise HTTPException(status_code=503, detail="设备登记删除失败")
|
||
return {
|
||
"code": 200,
|
||
"success": True,
|
||
"data": {"device_id": device_id, "deleted": True},
|
||
"message": "失效设备已删除,历史命令记录保留用于审计",
|
||
}
|
||
|
||
|
||
@router.get("/devices/{device_id}/heartbeat", response_model=dict)
|
||
async def get_device_heartbeat(device_id: str):
|
||
"""查询设备心跳状态"""
|
||
status = ws_hub.get_heartbeat_status(device_id)
|
||
if not status:
|
||
raise HTTPException(status_code=404, detail="设备不存在或未上报心跳")
|
||
return {"code": 200, "data": status}
|
||
|
||
|
||
@router.post("/devices/{device_id}/heartbeat/config", response_model=dict)
|
||
async def set_device_heartbeat(device_id: str, req: HeartbeatConfigRequest):
|
||
"""下发设备心跳配置(5-120 秒)"""
|
||
ok = await ws_hub.set_heartbeat_interval(device_id, req.heartbeat_interval_seconds)
|
||
if not ok:
|
||
raise HTTPException(status_code=400, detail="heartbeat_interval_seconds 必须在 5-120 之间")
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"device_id": device_id,
|
||
"heartbeat_interval_seconds": req.heartbeat_interval_seconds,
|
||
},
|
||
}
|
||
|
||
|
||
# ========== 设备控制 ==========
|
||
|
||
@router.post("/devices/{device_id}/screenshot", response_model=dict, responses={503: {"model": DeviceOperationReceipt}})
|
||
async def screenshot(device_id: str):
|
||
"""获取设备截图,仅经 WSS Agent 下发。"""
|
||
|
||
if not ws_hub.is_online(device_id):
|
||
return _ws_offline_receipt(device_id, "screenshot")
|
||
|
||
try:
|
||
result = await ws_hub.send_command(device_id, {
|
||
"type": "execute",
|
||
"data": {
|
||
"action": "screenshot"
|
||
}
|
||
}, timeout=12)
|
||
except Exception as exc:
|
||
return _ws_offline_receipt(device_id, "screenshot", f"ws_exception:{type(exc).__name__}")
|
||
|
||
if result.get("code") != 200:
|
||
return _ws_failure_receipt(device_id, "screenshot", result)
|
||
|
||
data = result.get("data", {})
|
||
# Android Agent 使用 image_base64;旧 Python Agent 使用 base64。
|
||
if not data.get("base64") and data.get("image_base64"):
|
||
data["base64"] = data["image_base64"]
|
||
if not data.get("base64"):
|
||
return _ws_failure_receipt(device_id, "screenshot", {**result, "message": "ws_empty_screenshot"})
|
||
data["collection_channel"] = "agent_internal_screenshot"
|
||
result["data"] = data
|
||
return _ws_receipt(result, device_id=device_id, action="screenshot")
|
||
|
||
|
||
@router.post("/devices/{device_id}/click", response_model=dict, responses={503: {"model": DeviceOperationReceipt}})
|
||
async def click(device_id: str, req: ClickRequest):
|
||
"""点击坐标"""
|
||
|
||
if not ws_hub.is_online(device_id):
|
||
return _ws_offline_receipt(device_id, "click")
|
||
|
||
result = await ws_hub.send_command(device_id, {
|
||
"type": "execute",
|
||
"data": {
|
||
"action": "click",
|
||
"params": {"x": req.x, "y": req.y}
|
||
}
|
||
})
|
||
|
||
if result.get("code") != 200:
|
||
return _ws_failure_receipt(device_id, "click", result)
|
||
return _ws_receipt(result, device_id=device_id, action="click")
|
||
|
||
|
||
@router.post("/devices/{device_id}/click-text", response_model=dict, responses={503: {"model": DeviceOperationReceipt}})
|
||
async def click_text(device_id: str, req: ClickTextRequest):
|
||
"""点击文字"""
|
||
|
||
if not ws_hub.is_online(device_id):
|
||
return _ws_offline_receipt(device_id, "click_text")
|
||
|
||
result = await ws_hub.send_command(device_id, {
|
||
"type": "execute",
|
||
"data": {
|
||
"action": "click_text",
|
||
"params": {"text": req.text, "timeout": req.timeout}
|
||
}
|
||
})
|
||
|
||
if result.get("code") != 200:
|
||
return _ws_failure_receipt(device_id, "click_text", result)
|
||
return _ws_receipt(result, device_id=device_id, action="click_text")
|
||
|
||
|
||
@router.post("/devices/{device_id}/input", response_model=dict, responses={503: {"model": DeviceOperationReceipt}})
|
||
async def input_text(device_id: str, req: InputRequest):
|
||
"""输入文字"""
|
||
|
||
if not ws_hub.is_online(device_id):
|
||
return _ws_offline_receipt(device_id, "input")
|
||
|
||
result = await ws_hub.send_command(device_id, {
|
||
"type": "execute",
|
||
"data": {
|
||
"action": "input",
|
||
"params": {"text": req.text, "clear": req.clear}
|
||
}
|
||
})
|
||
|
||
if result.get("code") != 200:
|
||
return _ws_failure_receipt(device_id, "input", result)
|
||
return _ws_receipt(result, device_id=device_id, action="input")
|
||
|
||
|
||
@router.post("/devices/{device_id}/swipe", response_model=dict, responses={422: {"model": DeviceOperationReceipt}, 503: {"model": DeviceOperationReceipt}})
|
||
async def swipe(device_id: str, req: SwipeRequest):
|
||
"""滑动;持续时长由 Agent 支持后另行开放。"""
|
||
|
||
if req.duration is not None:
|
||
return JSONResponse(
|
||
status_code=422,
|
||
content={
|
||
"code": 422,
|
||
"success": False,
|
||
"data": {},
|
||
"error_code": "duration_unsupported",
|
||
"error_message": "swipe.duration 已弃用;请仅传 direction 与 scale",
|
||
"retryable": False,
|
||
"trace_id": None,
|
||
"channel_used": "contract/validation",
|
||
"raw_rpc_receipt": None,
|
||
"readback": None,
|
||
},
|
||
)
|
||
|
||
if not ws_hub.is_online(device_id):
|
||
return _ws_offline_receipt(device_id, "swipe")
|
||
|
||
result = await ws_hub.send_command(device_id, {
|
||
"type": "execute",
|
||
"data": {
|
||
"action": "swipe",
|
||
"params": {"direction": req.direction, "scale": req.scale}
|
||
}
|
||
})
|
||
|
||
if result.get("code") != 200:
|
||
return _ws_failure_receipt(device_id, "swipe", result)
|
||
return _ws_receipt(result, device_id=device_id, action="swipe")
|
||
|
||
|
||
@router.get("/devices/{device_id}/ui-tree", response_model=dict, responses={503: {"model": DeviceOperationReceipt}})
|
||
async def get_ui_tree(device_id: str):
|
||
"""获取 UI 树;外层执行固定为 WSS Agent。"""
|
||
|
||
if not ws_hub.is_online(device_id):
|
||
return _ws_offline_receipt(device_id, "ui_tree")
|
||
|
||
result = await ws_hub.send_command(device_id, {
|
||
"type": "execute",
|
||
"data": {
|
||
"action": "ui_tree"
|
||
}
|
||
})
|
||
|
||
if result.get("code") != 200:
|
||
return _ws_failure_receipt(device_id, "ui_tree", result)
|
||
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
||
if data.get("channel") == "accessibility":
|
||
data["agent_collection_channel"] = "accessibility"
|
||
data["channel"] = "agent_internal_accessibility"
|
||
data["collection_scope"] = "agent_internal_device_diagnostic"
|
||
result["data"] = data
|
||
return _ws_receipt(result, device_id=device_id, action="ui_tree")
|
||
|
||
|
||
# ========== 脚本执行 ==========
|
||
|
||
@router.post("/devices/{device_id}/execute", response_model=dict)
|
||
async def execute_script(device_id: str, script: str, action: str, params: dict = {}, timeout: int = 30):
|
||
"""执行脚本"""
|
||
|
||
if not ws_hub.is_online(device_id):
|
||
raise HTTPException(status_code=503, detail="设备不在线")
|
||
|
||
result = await ws_hub.send_command(device_id, {
|
||
"type": "execute",
|
||
"data": {
|
||
"script": script,
|
||
"action": action,
|
||
"params": params
|
||
}
|
||
}, timeout=timeout)
|
||
|
||
await device_manager.log_command(device_id, f"{script}.{action}", params, result)
|
||
|
||
return {
|
||
"code": result.get("code", 200),
|
||
"message": result.get("message", ""),
|
||
"data": result.get("data", {}),
|
||
}
|
||
|
||
|
||
# ========== AI Brain 管理 ==========
|
||
|
||
@router.get("/devices/{device_id}/ai/status", response_model=dict)
|
||
async def get_ai_brain_status(device_id: str):
|
||
"""获取设备 AI Brain 状态"""
|
||
status = ws_hub.get_ai_brain_status(device_id)
|
||
if not status:
|
||
raise HTTPException(status_code=404, detail="设备不存在或未上线")
|
||
return {"code": 200, "data": status}
|
||
|
||
|
||
@router.post("/devices/{device_id}/ai/task", response_model=dict)
|
||
async def push_ai_task(device_id: str, req: AITaskRequest):
|
||
"""向设备推送 AI 任务(AI Brain 会在下次心跳周期执行)"""
|
||
if not ws_hub.is_online(device_id):
|
||
raise HTTPException(status_code=503, detail="设备不在线")
|
||
|
||
result = await ws_hub.send_command(device_id, {
|
||
"type": "ai_task",
|
||
"data": {"instruction": req.instruction, "priority": req.priority},
|
||
}, timeout=20)
|
||
if int(result.get("code", 500)) >= 400:
|
||
raise HTTPException(status_code=500, detail="推送失败")
|
||
receipt = _ws_receipt(result)
|
||
receipt["data"] = {**(receipt.get("data") or {}), "device_id": device_id,
|
||
"instruction": req.instruction, "priority": req.priority,
|
||
"status": "queued"}
|
||
return receipt
|
||
|
||
|
||
@router.post("/devices/{device_id}/ai/standing-order", response_model=dict)
|
||
async def push_standing_order(device_id: str, req: StandingOrderRequest):
|
||
"""向设备推送常驻指令(离线时自动执行)"""
|
||
if not ws_hub.is_online(device_id):
|
||
raise HTTPException(status_code=503, detail="设备不在线")
|
||
|
||
result = await ws_hub.send_command(device_id, {
|
||
"type": "standing_order",
|
||
"data": {"order": req.order},
|
||
}, timeout=20)
|
||
if int(result.get("code", 500)) >= 400:
|
||
raise HTTPException(status_code=500, detail="推送失败")
|
||
receipt = _ws_receipt(result)
|
||
receipt["data"] = {**(receipt.get("data") or {}), "device_id": device_id,
|
||
"order": req.order, "status": "pushed"}
|
||
return receipt
|
||
|
||
|
||
@router.post("/devices/{device_id}/ai/execute", response_model=dict)
|
||
async def ai_execute(device_id: str, task: str, timeout: int = 60):
|
||
"""让设备 AI Agent 执行自然语言任务(同步等待结果)"""
|
||
if not ws_hub.is_online(device_id):
|
||
raise HTTPException(status_code=503, detail="设备不在线")
|
||
|
||
result = await ws_hub.send_command(device_id, {
|
||
"type": "agent_execute",
|
||
"data": {"task": task},
|
||
}, timeout=timeout)
|
||
|
||
return _ws_receipt(result)
|
||
|
||
|
||
class DeviceAIChatRequest(BaseModel):
|
||
"""卡若网关决策 + WS 下发(手机无需本地 api_key)"""
|
||
instruction: str
|
||
timeout: int = 120
|
||
|
||
|
||
@router.post("/devices/{device_id}/ai/chat", response_model=dict)
|
||
async def device_ai_chat(device_id: str, req: DeviceAIChatRequest):
|
||
"""
|
||
小 AI 核心端点:服务端调卡若 /api/gateway/chat 决策,经 WS 让手机执行。
|
||
与日常卡若接口一致,不另建 Ollama/local-ai。
|
||
"""
|
||
from services.karuo_device_ai import chat_and_execute_on_device
|
||
|
||
result = await chat_and_execute_on_device(device_id, req.instruction, req.timeout)
|
||
code = int(result.get("code", 200))
|
||
if code >= 400:
|
||
raise HTTPException(status_code=code, detail=result.get("message", "执行失败"))
|
||
return {"code": 200, "data": result}
|
||
|
||
|
||
class HotUpdateRequest(BaseModel):
|
||
update_type: str = "config" # config | hub_html | refresh
|
||
config: Optional[dict] = None
|
||
content: Optional[str] = None
|
||
url: Optional[str] = None
|
||
|
||
|
||
@router.post("/devices/{device_id}/hot-update", response_model=dict)
|
||
async def device_hot_update(device_id: str, req: HotUpdateRequest):
|
||
"""热更新 Hub / 配置(如 ai_api_key),无需重装 APK"""
|
||
if not ws_hub.is_online(device_id):
|
||
raise HTTPException(status_code=503, detail="设备不在线")
|
||
payload: dict = {"update_type": req.update_type}
|
||
if req.config:
|
||
payload["config"] = req.config
|
||
if req.content:
|
||
payload["content"] = req.content
|
||
if req.url:
|
||
payload["url"] = req.url
|
||
result = await ws_hub.send_command(
|
||
device_id,
|
||
{"type": "ui_update", "data": payload},
|
||
timeout=60,
|
||
)
|
||
return {"code": result.get("code", 200), "data": result}
|
||
|
||
|
||
# ========== AI 心跳监控仪表盘 ==========
|
||
|
||
@router.get("/heartbeat/dashboard", response_model=dict)
|
||
async def heartbeat_dashboard():
|
||
"""AI 心跳监控仪表盘 — 所有设备健康总览"""
|
||
from services.ai_heartbeat import ai_heartbeat
|
||
return {"code": 200, "data": ai_heartbeat.get_dashboard()}
|
||
|
||
|
||
@router.get("/devices/{device_id}/health", response_model=dict)
|
||
async def device_health(device_id: str):
|
||
"""获取单台设备的 AI 健康评分与状态"""
|
||
from services.ai_heartbeat import ai_heartbeat
|
||
h = ai_heartbeat.get_device_health(device_id)
|
||
if not h:
|
||
raise HTTPException(status_code=404, detail="设备未纳入监控")
|
||
return {"code": 200, "data": h}
|
||
|
||
|
||
@router.get("/devices/{device_id}/health/events", response_model=dict)
|
||
async def device_health_events(device_id: str, limit: int = 30):
|
||
"""获取设备健康事件(告警历史)"""
|
||
from services.ai_heartbeat import ai_heartbeat
|
||
return {"code": 200, "data": ai_heartbeat.get_events(device_id, limit)}
|
||
|
||
|
||
@router.get("/heartbeat/events", response_model=dict)
|
||
async def all_health_events(limit: int = 50):
|
||
"""全局健康事件流"""
|
||
from services.ai_heartbeat import ai_heartbeat
|
||
return {"code": 200, "data": ai_heartbeat.get_events(limit=limit)}
|
||
|
||
|
||
@router.post("/devices/{device_id}/ai/queue-task", response_model=dict)
|
||
async def queue_ai_task(device_id: str, req: AITaskRequest):
|
||
"""向设备心跳任务队列添加待执行任务(心跳周期自动下发)"""
|
||
from services.ai_heartbeat import ai_heartbeat
|
||
ai_heartbeat.enqueue_task(device_id, {
|
||
"instruction": req.instruction,
|
||
"priority": req.priority,
|
||
})
|
||
return {
|
||
"code": 200,
|
||
"data": ai_heartbeat.get_task_queue_status(device_id),
|
||
}
|
||
|
||
|
||
# ========== 连接守护事件 ==========
|
||
|
||
_guard_event_store: dict = {}
|
||
_MAX_EVENTS_PER_DEVICE = 200
|
||
|
||
|
||
@router.get("/devices/{device_id}/guard-events", response_model=dict, tags=["连接守护"])
|
||
async def get_guard_events(device_id: str, limit: int = 50):
|
||
"""获取设备的连接守护事件(弹窗处理、异常告警等)"""
|
||
events = _guard_event_store.get(device_id, [])
|
||
return {"code": 200, "data": {"device_id": device_id, "events": events[-limit:], "total": len(events)}}
|
||
|
||
|
||
@router.post("/devices/{device_id}/guard-events", response_model=dict, tags=["连接守护"])
|
||
async def post_guard_event(device_id: str, event: dict):
|
||
"""上报连接守护事件(Agent 心跳或守护循环调用)"""
|
||
import time
|
||
event["server_received_at"] = time.time()
|
||
if device_id not in _guard_event_store:
|
||
_guard_event_store[device_id] = []
|
||
_guard_event_store[device_id].append(event)
|
||
if len(_guard_event_store[device_id]) > _MAX_EVENTS_PER_DEVICE:
|
||
_guard_event_store[device_id] = _guard_event_store[device_id][-_MAX_EVENTS_PER_DEVICE:]
|
||
return {"code": 200, "data": {"stored": True}}
|
||
|
||
|
||
@router.get("/guard-events/all", response_model=dict, tags=["连接守护"])
|
||
async def get_all_guard_events(limit: int = 100):
|
||
"""获取所有设备的守护事件(全局视图)"""
|
||
all_events = []
|
||
for did, events in _guard_event_store.items():
|
||
for evt in events:
|
||
all_events.append({**evt, "device_id": did})
|
||
all_events.sort(key=lambda x: x.get("server_received_at", 0), reverse=True)
|
||
return {"code": 200, "data": {"events": all_events[:limit], "total": len(all_events)}}
|