Files
workphone-sdk/sdk/app/routers/devices.py

540 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
工作手机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
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):
"""滑动请求"""
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
from services.device_id_util import enrich_device_id_fields, sanitize_sensitive_fields
enrich_device_id_fields(device)
# 若 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": sanitize_sensitive_fields(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):
"""获取设备截图WS Agent 优先,超时/失败时走 ADB 截图兜底。"""
async def _adb_fallback(reason: str):
from services.adb_device import adb_manager
dev = adb_manager.get_device(device_id)
if not dev:
raise HTTPException(status_code=503, detail=f"设备不在线,截图失败: {reason}")
result = dev.screenshot()
if result.get("code") != 200:
raise HTTPException(status_code=result.get("code", 500), detail=result.get("message") or reason)
data = result.get("data", {})
data["fallback"] = "adb"
data["fallback_reason"] = reason
return {"code": 200, "data": data}
if not ws_hub.is_online(device_id):
return await _adb_fallback("ws_offline")
try:
result = await ws_hub.send_command(device_id, {
"type": "execute",
"data": {
"action": "screenshot"
}
}, timeout=12)
except Exception as exc:
return await _adb_fallback(f"ws_exception:{exc}")
if result.get("code") != 200:
return await _adb_fallback(result.get("message") or f"ws_code:{result.get('code')}")
data = result.get("data", {})
if not data.get("base64"):
return await _adb_fallback("ws_empty_screenshot")
data["fallback"] = "ws"
return {"code": 200, "data": 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", {}),
}
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)}}