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

703 lines
24 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 __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)}}