Files
workphone-sdk/sdk/app/services/ai_heartbeat.py

420 lines
15 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.

"""
AI 心跳监控服务
实时监控设备健康状态,异常检测与自动恢复,任务队列与心跳周期集成。
核心能力:
1. 设备健康评分0-100
2. 异常检测(连接断开、心跳丢失、电量低、存储满等)
3. 自动恢复触发ADB重连、Agent App重启、反向代理恢复
4. 心跳周期内下发待执行 AI 任务
5. 设备状态仪表盘数据聚合
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Deque, Dict, List, Optional
logger = logging.getLogger(__name__)
class AlertLevel(str, Enum):
INFO = "info"
WARNING = "warning"
CRITICAL = "critical"
@dataclass
class DeviceHealthEvent:
device_id: str
level: AlertLevel
category: str
message: str
ts: str = field(default_factory=lambda: datetime.now().isoformat())
meta: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"device_id": self.device_id,
"level": self.level.value,
"category": self.category,
"message": self.message,
"ts": self.ts,
**self.meta,
}
@dataclass
class DeviceHealth:
device_id: str
score: int = 100
last_heartbeat: Optional[str] = None
heartbeat_age_sec: int = 0
battery_level: int = -1
battery_charging: bool = False
memory_usage_pct: float = 0.0
storage_free_mb: int = -1
screen_on: bool = False
network_type: str = "unknown"
current_app: str = ""
wechat_running: bool = False
ws_connected: bool = False
adb_connected: bool = False
hook_available: bool = False
ai_ready: bool = False
uptime_sec: int = 0
consecutive_missed: int = 0
def to_dict(self) -> dict:
return {
"device_id": self.device_id,
"score": self.score,
"last_heartbeat": self.last_heartbeat,
"heartbeat_age_sec": self.heartbeat_age_sec,
"battery": {"level": self.battery_level, "charging": self.battery_charging},
"memory_usage_pct": self.memory_usage_pct,
"storage_free_mb": self.storage_free_mb,
"screen_on": self.screen_on,
"network_type": self.network_type,
"current_app": self.current_app,
"wechat_running": self.wechat_running,
"connections": {
"ws": self.ws_connected,
"adb": self.adb_connected,
"hook": self.hook_available,
"ai_ready": self.ai_ready,
},
"uptime_sec": self.uptime_sec,
"consecutive_missed": self.consecutive_missed,
}
class AIHeartbeatMonitor:
"""
AI 心跳监控中枢。
由 main.py lifespan 启动后台循环,每个心跳周期:
1. 扫描所有在线设备
2. 计算健康分
3. 检测异常 → 生成告警
4. 触发自动恢复
5. 下发待执行任务
"""
MAX_EVENTS = 200
HEALTH_INTERVAL_SEC = 10
def __init__(self):
self._health: Dict[str, DeviceHealth] = {}
self._events: Deque[DeviceHealthEvent] = deque(maxlen=self.MAX_EVENTS)
self._task_queue: Dict[str, Deque[dict]] = {}
self._running = False
self._recovery_cooldown: Dict[str, float] = {}
# ------------------------------------------------------------------
# 生命周期
# ------------------------------------------------------------------
async def start(self):
self._running = True
logger.info("AI 心跳监控已启动")
async def stop(self):
self._running = False
logger.info("AI 心跳监控已停止")
async def tick(self):
"""主循环中每次被调用(由 main.py _ai_heartbeat_loop 驱动)"""
if not self._running:
return
from services.ws_hub import ws_hub
now = datetime.now()
for device_id, info in list(ws_hub.device_info.items()):
health = self._health.setdefault(device_id, DeviceHealth(device_id=device_id))
await self._update_health_from_info(health, info, now)
self._compute_score(health)
self._detect_anomalies(health)
await self._dispatch_pending_tasks(device_id)
for device_id in list(self._health.keys()):
if device_id not in ws_hub.device_info and device_id not in ws_hub.connections:
health = self._health[device_id]
health.ws_connected = False
health.score = max(0, health.score - 5)
if health.consecutive_missed > 3:
await self._try_recovery(device_id, "ws_lost")
# ------------------------------------------------------------------
# 健康数据更新
# ------------------------------------------------------------------
async def _update_health_from_info(self, h: DeviceHealth, info: dict, now: datetime):
from services.ws_hub import ws_hub
h.ws_connected = h.device_id in ws_hub.connections
h.last_heartbeat = info.get("last_heartbeat")
if h.last_heartbeat:
try:
dt = datetime.fromisoformat(h.last_heartbeat)
h.heartbeat_age_sec = max(0, int((now - dt).total_seconds()))
except Exception:
h.heartbeat_age_sec = 9999
qs = info.get("quick_status") or {}
h.battery_level = qs.get("battery_level", h.battery_level)
h.battery_charging = qs.get("battery_charging", h.battery_charging)
h.memory_usage_pct = qs.get("memory_usage_pct", h.memory_usage_pct)
h.storage_free_mb = qs.get("storage_free_mb", h.storage_free_mb)
h.screen_on = qs.get("screen_on", h.screen_on) or info.get("screen_on", h.screen_on)
h.network_type = qs.get("network_type", h.network_type) or info.get("network_type", h.network_type)
h.current_app = qs.get("current_app", h.current_app) or info.get("current_app", h.current_app)
h.wechat_running = qs.get("wechat_running", h.wechat_running) or info.get("wechat_running", h.wechat_running)
h.uptime_sec = qs.get("uptime_sec", h.uptime_sec)
h.hook_available = info.get("frida_available", False)
h.ai_ready = h.ws_connected or h.adb_connected
from services.connection_priority import connection_priority, ControlMode
try:
modes = await connection_priority.async_evaluate(h.device_id)
for m in modes:
if m.mode == ControlMode.ADB:
h.adb_connected = m.available
except Exception:
pass
def _compute_score(self, h: DeviceHealth):
"""综合健康评分 0-100"""
score = 100
if not h.ws_connected:
score -= 30
if not h.adb_connected:
score -= 15
hb_interval = 10
if h.heartbeat_age_sec > hb_interval * 3:
score -= 20
h.consecutive_missed = h.heartbeat_age_sec // max(hb_interval, 1)
elif h.heartbeat_age_sec > hb_interval * 2:
score -= 10
h.consecutive_missed = max(1, h.heartbeat_age_sec // max(hb_interval, 1) - 1)
else:
h.consecutive_missed = 0
if 0 <= h.battery_level <= 15 and not h.battery_charging:
score -= 15
elif 0 <= h.battery_level <= 30 and not h.battery_charging:
score -= 5
if h.memory_usage_pct > 90:
score -= 10
elif h.memory_usage_pct > 80:
score -= 5
if 0 < h.storage_free_mb < 500:
score -= 10
elif 0 < h.storage_free_mb < 1000:
score -= 5
if h.network_type in ("none", "unknown", ""):
score -= 10
h.score = max(0, min(100, score))
# ------------------------------------------------------------------
# 异常检测
# ------------------------------------------------------------------
def _detect_anomalies(self, h: DeviceHealth):
did = h.device_id
if h.consecutive_missed >= 5:
self._emit(did, AlertLevel.CRITICAL, "heartbeat",
f"心跳连续丢失 {h.consecutive_missed}")
if 0 <= h.battery_level <= 10 and not h.battery_charging:
self._emit(did, AlertLevel.CRITICAL, "battery",
f"电量极低 {h.battery_level}%,即将关机")
elif 0 <= h.battery_level <= 20 and not h.battery_charging:
self._emit(did, AlertLevel.WARNING, "battery",
f"电量低 {h.battery_level}%")
if 0 < h.storage_free_mb < 200:
self._emit(did, AlertLevel.CRITICAL, "storage",
f"存储空间严重不足 {h.storage_free_mb}MB")
elif 0 < h.storage_free_mb < 500:
self._emit(did, AlertLevel.WARNING, "storage",
f"存储空间不足 {h.storage_free_mb}MB")
if h.memory_usage_pct > 95:
self._emit(did, AlertLevel.CRITICAL, "memory",
f"内存使用率 {h.memory_usage_pct:.0f}%,可能 OOM")
if h.network_type in ("none", ""):
self._emit(did, AlertLevel.WARNING, "network", "网络不可用")
if h.score <= 30:
self._emit(did, AlertLevel.CRITICAL, "health",
f"设备健康分 {h.score},需要紧急关注")
elif h.score <= 60:
self._emit(did, AlertLevel.WARNING, "health",
f"设备健康分 {h.score},建议检查")
def _emit(self, device_id: str, level: AlertLevel, category: str, message: str, **meta):
recent = [e for e in self._events if e.device_id == device_id and e.category == category]
if recent:
last = recent[-1]
try:
age = (datetime.now() - datetime.fromisoformat(last.ts)).total_seconds()
if age < 60:
return
except Exception:
pass
evt = DeviceHealthEvent(device_id=device_id, level=level, category=category,
message=message, meta=meta)
self._events.append(evt)
log_fn = logger.warning if level in (AlertLevel.WARNING, AlertLevel.CRITICAL) else logger.info
log_fn(f"[AI心跳] [{level.value}] {device_id}: {message}")
# ------------------------------------------------------------------
# 自动恢复
# ------------------------------------------------------------------
async def _try_recovery(self, device_id: str, reason: str):
cooldown_key = f"{device_id}:{reason}"
now = time.time()
if now - self._recovery_cooldown.get(cooldown_key, 0) < 120:
return
self._recovery_cooldown[cooldown_key] = now
logger.info(f"[AI心跳] 尝试自动恢复 {device_id} ({reason})")
self._emit(device_id, AlertLevel.INFO, "recovery",
f"触发自动恢复: {reason}")
if reason == "ws_lost":
await self._recover_ws(device_id)
async def _recover_ws(self, device_id: str):
"""尝试恢复 WebSocket 连接(通过 ADB 重启 Agent App"""
from services.adb_device import adb_manager
serial = adb_manager.resolve_serial(device_id)
if not serial:
return
dev = adb_manager.get_device(serial)
if not dev or not dev.is_online():
return
try:
dev._shell("am force-stop com.workphone.agent", timeout=5)
await asyncio.sleep(2)
dev._shell(
"am start -n com.workphone.agent/.MainActivity "
"-a com.workphone.agent.START",
timeout=5,
)
logger.info(f"[AI心跳] 已通过 ADB 重启 Agent App: {device_id}")
except Exception as e:
logger.warning(f"[AI心跳] 恢复失败: {e}")
# ------------------------------------------------------------------
# 任务队列
# ------------------------------------------------------------------
def enqueue_task(self, device_id: str, task: dict):
"""向设备的心跳任务队列添加待执行任务"""
q = self._task_queue.setdefault(device_id, deque(maxlen=50))
task.setdefault("queued_at", datetime.now().isoformat())
q.append(task)
logger.info(f"[AI心跳] 任务入队 {device_id}: {task.get('instruction', '')[:50]}")
async def _dispatch_pending_tasks(self, device_id: str):
"""在心跳周期内下发待执行任务"""
q = self._task_queue.get(device_id)
if not q:
return
from services.ws_hub import ws_hub
if not ws_hub.is_online(device_id):
return
batch_limit = 3
dispatched = 0
while q and dispatched < batch_limit:
task = q.popleft()
ok = await ws_hub.send_to_device(device_id, {
"type": "ai_task",
"data": task,
})
if ok:
dispatched += 1
logger.info(f"[AI心跳] 任务下发 {device_id}: {task.get('instruction', '')[:50]}")
else:
q.appendleft(task)
break
# ------------------------------------------------------------------
# 查询 API
# ------------------------------------------------------------------
def get_device_health(self, device_id: str) -> Optional[dict]:
h = self._health.get(device_id)
return h.to_dict() if h else None
def get_all_health(self) -> List[dict]:
return [h.to_dict() for h in self._health.values()]
def get_events(self, device_id: Optional[str] = None, limit: int = 50) -> List[dict]:
evts = list(self._events)
if device_id:
evts = [e for e in evts if e.device_id == device_id]
return [e.to_dict() for e in evts[-limit:]]
def get_dashboard(self) -> dict:
"""聚合仪表盘数据"""
all_h = list(self._health.values())
total = len(all_h)
healthy = sum(1 for h in all_h if h.score >= 80)
warning = sum(1 for h in all_h if 50 <= h.score < 80)
critical = sum(1 for h in all_h if h.score < 50)
ws_up = sum(1 for h in all_h if h.ws_connected)
adb_up = sum(1 for h in all_h if h.adb_connected)
pending_tasks = sum(len(q) for q in self._task_queue.values())
recent_alerts = [e.to_dict() for e in list(self._events)[-10:]]
return {
"total_devices": total,
"healthy": healthy,
"warning": warning,
"critical": critical,
"ws_connected": ws_up,
"adb_connected": adb_up,
"avg_score": round(sum(h.score for h in all_h) / max(total, 1), 1),
"pending_tasks": pending_tasks,
"recent_alerts": recent_alerts,
"devices": [h.to_dict() for h in sorted(all_h, key=lambda x: x.score)],
}
def get_task_queue_status(self, device_id: str) -> dict:
q = self._task_queue.get(device_id, deque())
return {
"device_id": device_id,
"pending_count": len(q),
"tasks": [t for t in q],
}
ai_heartbeat = AIHeartbeatMonitor()