468 lines
15 KiB
Python
468 lines
15 KiB
Python
"""
|
||
工作手机SDK v3.0 - 设备管理路由
|
||
"""
|
||
|
||
from fastapi import APIRouter, HTTPException, Depends
|
||
from typing import List, Optional
|
||
from pydantic import BaseModel
|
||
|
||
from services.ws_hub import ws_hub
|
||
from services.device_manager import device_manager
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
# ========== 数据模型 ==========
|
||
|
||
class DeviceResponse(BaseModel):
|
||
"""设备信息响应"""
|
||
device_id: str
|
||
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):
|
||
"""滑动请求"""
|
||
direction: str # up, down, left, right
|
||
scale: float = 0.8
|
||
|
||
|
||
class HeartbeatConfigRequest(BaseModel):
|
||
"""心跳配置请求"""
|
||
heartbeat_interval_seconds: int
|
||
|
||
|
||
class AITaskRequest(BaseModel):
|
||
"""AI 任务推送请求"""
|
||
instruction: str
|
||
priority: int = 5
|
||
|
||
|
||
class StandingOrderRequest(BaseModel):
|
||
"""常驻指令推送请求"""
|
||
order: str
|
||
|
||
|
||
# ========== 设备列表 ==========
|
||
|
||
@router.get("/devices", response_model=dict)
|
||
async def get_devices():
|
||
"""获取所有设备列表(融合 WebSocket + ADB + DB)"""
|
||
from services.device_fleet import list_merged_local_devices
|
||
|
||
devices = await list_merged_local_devices()
|
||
return {"code": 200, "data": devices}
|
||
|
||
|
||
@router.get("/devices/{device_id}", response_model=dict)
|
||
async def get_device(device_id: str):
|
||
"""获取设备详情(支持 WebSocket / ADB / DB)"""
|
||
from services.adb_device import adb_manager
|
||
|
||
# 检查 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
|
||
|
||
# 检查 ADB 直连(在线程池中执行以避免阻塞)
|
||
adb_info = None
|
||
import asyncio
|
||
|
||
def _check_adb():
|
||
dev = adb_manager.get_device(device_id)
|
||
if dev and dev.is_online():
|
||
info = dev.get_info()
|
||
info["connection_type"] = "adb"
|
||
return info
|
||
return None
|
||
try:
|
||
adb_info = await asyncio.get_running_loop().run_in_executor(None, _check_adb)
|
||
except Exception:
|
||
pass
|
||
|
||
if not online_info and not db_info and not adb_info:
|
||
raise HTTPException(status_code=404, detail="设备不存在")
|
||
|
||
device = {**(db_info or {}), **(adb_info or {}), **(online_info or {})}
|
||
if online_info:
|
||
device["status"] = "online"
|
||
elif adb_info:
|
||
device["status"] = "adb"
|
||
else:
|
||
device["status"] = "offline"
|
||
device["device_id"] = device_id
|
||
|
||
# 若 model/brand/android_version 为空或 Unknown,从 ADB 补充(设备可能同时连 WS 和 USB)
|
||
def _is_empty_or_unknown(v) -> bool:
|
||
return not v or (isinstance(v, str) and v.strip().lower() in ("", "unknown", "未知"))
|
||
for field in ("model", "brand", "android_version"):
|
||
if _is_empty_or_unknown(device.get(field)) and adb_info and adb_info.get(field):
|
||
device[field] = adb_info[field]
|
||
# 若仍缺失,尝试从 adb_manager 实时拉取(设备可能刚连上 ADB)
|
||
if _is_empty_or_unknown(device.get("model")) or _is_empty_or_unknown(device.get("android_version")):
|
||
try:
|
||
dev = adb_manager.get_device(device_id)
|
||
if dev and dev.is_online():
|
||
info = await asyncio.wait_for(
|
||
asyncio.get_running_loop().run_in_executor(None, dev.get_info),
|
||
timeout=5.0,
|
||
)
|
||
for field in ("model", "brand", "android_version"):
|
||
if _is_empty_or_unknown(device.get(field)) and info.get(field):
|
||
device[field] = info[field]
|
||
except (asyncio.TimeoutError, Exception):
|
||
pass
|
||
|
||
return {"code": 200, "data": device}
|
||
|
||
|
||
@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)
|
||
async def screenshot(device_id: str):
|
||
"""获取设备截图"""
|
||
|
||
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": {
|
||
"action": "screenshot"
|
||
}
|
||
})
|
||
|
||
if result.get("code") != 200:
|
||
raise HTTPException(status_code=result.get("code", 500), detail=result.get("message"))
|
||
|
||
return {"code": 200, "data": result.get("data", {})}
|
||
|
||
|
||
@router.post("/devices/{device_id}/click", response_model=dict)
|
||
async def click(device_id: str, req: ClickRequest):
|
||
"""点击坐标"""
|
||
|
||
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": {
|
||
"action": "click",
|
||
"params": {"x": req.x, "y": req.y}
|
||
}
|
||
})
|
||
|
||
return {"code": 200, "data": result.get("data", {})}
|
||
|
||
|
||
@router.post("/devices/{device_id}/click-text", response_model=dict)
|
||
async def click_text(device_id: str, req: ClickTextRequest):
|
||
"""点击文字"""
|
||
|
||
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": {
|
||
"action": "click_text",
|
||
"params": {"text": req.text, "timeout": req.timeout}
|
||
}
|
||
})
|
||
|
||
return {"code": 200, "data": result.get("data", {})}
|
||
|
||
|
||
@router.post("/devices/{device_id}/input", response_model=dict)
|
||
async def input_text(device_id: str, req: InputRequest):
|
||
"""输入文字"""
|
||
|
||
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": {
|
||
"action": "input",
|
||
"params": {"text": req.text, "clear": req.clear}
|
||
}
|
||
})
|
||
|
||
return {"code": 200, "data": result.get("data", {})}
|
||
|
||
|
||
@router.post("/devices/{device_id}/swipe", response_model=dict)
|
||
async def swipe(device_id: str, req: SwipeRequest):
|
||
"""滑动"""
|
||
|
||
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": {
|
||
"action": "swipe",
|
||
"params": {"direction": req.direction, "scale": req.scale}
|
||
}
|
||
})
|
||
|
||
return {"code": 200, "data": result.get("data", {})}
|
||
|
||
|
||
@router.get("/devices/{device_id}/ui-tree", response_model=dict)
|
||
async def get_ui_tree(device_id: str):
|
||
"""获取UI树"""
|
||
|
||
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": {
|
||
"action": "ui_tree"
|
||
}
|
||
})
|
||
|
||
return {"code": 200, "data": result.get("data", {})}
|
||
|
||
|
||
# ========== 脚本执行 ==========
|
||
|
||
@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="设备不在线")
|
||
|
||
ok = await ws_hub.push_ai_task(device_id, req.instruction, req.priority)
|
||
if not ok:
|
||
raise HTTPException(status_code=500, detail="推送失败")
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"device_id": device_id,
|
||
"instruction": req.instruction,
|
||
"priority": req.priority,
|
||
"status": "queued",
|
||
},
|
||
}
|
||
|
||
|
||
@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="设备不在线")
|
||
|
||
ok = await ws_hub.push_standing_order(device_id, req.order)
|
||
if not ok:
|
||
raise HTTPException(status_code=500, detail="推送失败")
|
||
return {
|
||
"code": 200,
|
||
"data": {
|
||
"device_id": device_id,
|
||
"order": req.order,
|
||
"status": "pushed",
|
||
},
|
||
}
|
||
|
||
|
||
@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 {
|
||
"code": result.get("code", 200),
|
||
"message": result.get("message", ""),
|
||
"data": result.get("data", {}),
|
||
}
|
||
|
||
|
||
# ========== 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)}}
|