modify: aibox_sophon_adapter plugins

This commit is contained in:
2026-07-27 14:28:07 +08:00
parent 2b7b8ee08c
commit 79eb7fe5cc
23 changed files with 1380 additions and 61 deletions
+149
View File
@@ -0,0 +1,149 @@
"""
AI Box Sophon Adapter 插件
基于 MQTT 的 AI Box 设备心跳适配器,实现:
1. 通过 MQTT 主题 /board_ping 接收设备心跳上报
2. 解析设备系统参数(温度、硬盘、内存、通道、任务、TPU 等)
3. 持久化心跳数据到 SQLite 数据库
4. 提供 HTTP API 查询设备在线状态和详情
5. 后台定时检查离线设备,触发异常告警
心跳超时机制:
- 设备按配置间隔(默认 5 秒)上报心跳
- 服务端每 check_interval 秒检查一次
- 超过 heartbeat_timeout 秒未收到心跳的设备标记为离线
"""
import asyncio
import logging
from datetime import datetime
from pathlib import Path
from chongming_worker import auto_bind_models
from chongming_worker.runtime import create_workers as _create_workers
from .router import *
logger = logging.getLogger(__name__)
# 自动绑定 Pydantic 模型(用于 gen_models 生成的模型)
auto_bind_models("aibox_sophon_adapter")
# 离线检查任务引用
_offline_check_task: asyncio.Task | None = None
async def _check_offline_devices(interval: float):
"""
后台任务:定期检查离线设备
每隔 interval 秒扫描数据库,将超过 heartbeat_timeout 秒
未收到心跳的设备标记为离线,并输出告警日志。
"""
from .global_var import db, heartbeat_timeout
while True:
await asyncio.sleep(interval)
try:
if db is None:
continue
async with db.get_session() as session:
from .models.orm import BoardHeartbeat
from sqlalchemy import select
stmt = select(BoardHeartbeat).where(BoardHeartbeat.is_online == True)
result = await session.execute(stmt)
records = result.scalars().all()
now = datetime.now()
offline_count = 0
for record in records:
elapsed = (now - record.last_ping_time).total_seconds()
if elapsed > heartbeat_timeout:
record.is_online = False
record.updated_at = now
offline_count += 1
logger.warning(
f"[Offline] 设备 {record.board_id} (IP: {record.board_ip}) "
f"已离线 {elapsed:.0f} 秒(超时阈值: {heartbeat_timeout}s)"
)
if offline_count > 0:
await session.commit()
logger.info(f"[Offline] 本轮标记 {offline_count} 个设备为离线")
except Exception as e:
logger.error(f"[OfflineChecker] 检查异常: {e}")
async def create_workers(session, config, count=1, worker_manager=None):
"""
插件入口 — 初始化数据库和全局资源,创建 Worker 副本
由 FastAPI lifespan 在启动时调用。
"""
from .global_var import set_global_var, heartbeat_timeout as _default_timeout
from chongming_database import Database
# 读取心跳配置
worker_config = config.get("worker", {}).get("heartbeat", {}) if isinstance(config, dict) else {}
hb_interval = worker_config.get("interval", 5)
hb_timeout = worker_config.get("timeout", _default_timeout)
check_interval = worker_config.get("check_interval", 10)
# 设定全局变量
global heartbeat_timeout
heartbeat_timeout = hb_timeout
# 初始化数据库
data_dir = Path(config.get("data_dir", "./data")) if isinstance(config, dict) else Path("./data")
db_path = data_dir / "storage" / "chongming" / "aibox_sophon_adapter.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
db = Database(db_path)
await db.initialize()
# 创建表
from .models.orm import BoardHeartbeat
async with db.get_session() as sess:
from sqlmodel import SQLModel
await sess.run_sync(lambda s: SQLModel.metadata.create_all(s.bind))
set_global_var("db", db)
set_global_var("heartbeat_timeout", hb_timeout)
set_global_var("heartbeat_interval", hb_interval)
logger.info(
f"[aibox_sophon_adapter] 已初始化: "
f"心跳间隔={hb_interval}s, 超时={hb_timeout}s, 检查间隔={check_interval}s"
)
# 启动离线设备检查后台任务
global _offline_check_task
_offline_check_task = asyncio.create_task(_check_offline_devices(check_interval))
# 创建 Worker 副本
return _create_workers(session, config, count, worker_manager)
async def cleanup(worker_item: str):
"""
清理资源
在 Worker 关闭时调用,关闭数据库连接和后台任务。
"""
from .global_var import db
global _offline_check_task
if _offline_check_task is not None and not _offline_check_task.done():
_offline_check_task.cancel()
try:
await _offline_check_task
except asyncio.CancelledError:
pass
logger.info("[aibox_sophon_adapter] 离线检查任务已停止")
if db is not None:
await db.dispose()
logger.info(f"[aibox_sophon_adapter] {worker_item} 清理完毕")
+36
View File
@@ -0,0 +1,36 @@
"""
AI Box Sophon Adapter — 全局变量
模块级共享资源管理。
"""
from typing import Any
from chongming_database import Database
# 数据库实例
db: Database | None = None
# 心跳超时配置(秒)
heartbeat_timeout: float = 15.0
# 心跳上报间隔(秒)
heartbeat_interval: float = 5.0
def set_global_var(name: str, value: Any) -> None:
"""
设置当前模块的全局变量。
Args:
name: 变量名(字符串)
value: 任意值
"""
globals()[name] = value
__all__ = [
"db",
"heartbeat_timeout",
"heartbeat_interval",
"set_global_var",
]
+45
View File
@@ -0,0 +1,45 @@
"""
模型导出 — aibox_sophon_adapter
包含 gen_models 自动生成的模型和手动定义的 board_models。
"""
from .pydantic import (
AiboxSophonAdapterResponse,
GetDeviceGetRequest,
GetDeviceGetResponse,
GetOfflineDevicesGetResponse,
ListDevicesGetResponse,
)
from .board_models import (
BoardPingRequest,
HostDiskInfo,
MediaInfo,
MediaStatusInfo,
AlgItemInfo,
AlgTaskStatusInfo,
TaskInfo,
TpuInfo,
DeviceSummary,
)
from .orm import BoardHeartbeat
__all__ = [
# 自动生成
"AiboxSophonAdapterResponse",
"GetDeviceGetRequest",
"GetDeviceGetResponse",
"GetOfflineDevicesGetResponse",
"ListDevicesGetResponse",
# 手动定义
"BoardPingRequest",
"HostDiskInfo",
"MediaInfo",
"MediaStatusInfo",
"AlgItemInfo",
"AlgTaskStatusInfo",
"TaskInfo",
"TpuInfo",
"DeviceSummary",
# ORM
"BoardHeartbeat",
]
+133
View File
@@ -0,0 +1,133 @@
"""
AI Box Sophon Adapter — 设备心跳数据 Pydantic 模型(手动定义)
定义 BoardPingRequest 及其嵌套子结构。
这些模型无法通过 gen_models 自动生成(嵌套层次过深),需要手动维护。
对应 MQTT 主题 /board_ping 的上报数据结构。
命名约定:
- Pydantic 模型类名以 Info 结尾(如 HostDiskInfo),避免与 JSON 字段名冲突
- 字段名直接对应 JSON key,无需 alias
"""
from typing import Optional
from pydantic import BaseModel, Field
# ============================================================
# 心跳数据子结构
# ============================================================
class HostDiskInfo(BaseModel):
"""设备硬盘使用情况"""
Available: int = Field(..., description="可用空间 (kB)")
Total: int = Field(..., description="总空间 (kB)")
Used: int = Field(..., description="已用空间 (kB)")
class MediaStatusInfo(BaseModel):
"""通道状态描述"""
label: str = Field(..., description="状态标签,如 '正常'")
type: int = Field(..., description="状态类型码")
class MediaInfo(BaseModel):
"""设备通道信息"""
MediaName: str = Field(..., description="通道名称")
MediaStatus: MediaStatusInfo = Field(..., description="通道状态")
MediaUrl: str = Field(..., description="通道视频流地址")
SubId: str = Field(default="", description="关联 GB28181 通道号")
class AlgItemInfo(BaseModel):
"""算法配置信息"""
lineDesc: str = Field(default="", description="线描述")
lineRequired: bool = Field(default=False, description="是否需要线")
majorId: int = Field(..., description="主 ID")
majorOnly: bool = Field(default=False, description="仅主算法")
minorId: int = Field(..., description="副 ID")
name: str = Field(..., description="算法名称")
zoneDesc: str = Field(default="", description="区域描述")
zoneRequired: bool = Field(default=False, description="是否需要区域")
class AlgTaskStatusInfo(BaseModel):
"""任务状态描述"""
label: str = Field(..., description="状态标签,如 '运行中'")
type: int = Field(..., description="状态类型码")
class TaskInfo(BaseModel):
"""设备任务信息"""
AlgItem: list[AlgItemInfo] = Field(default_factory=list, description="算法配置列表")
AlgTaskSession: str = Field(..., description="任务号")
AlgTaskStatus: AlgTaskStatusInfo = Field(..., description="任务状态")
class TpuInfo(BaseModel):
"""TPU 算力资源使用情况"""
mem_total: int = Field(..., description="内存总量")
mem_usage: int = Field(..., description="内存使用量")
tpu_usage: int = Field(..., description="TPU 使用率")
# ============================================================
# 心跳请求体(MQTT 上报的完整数据结构)
# ============================================================
class BoardPingRequest(BaseModel):
"""
AI Box 设备心跳上报数据
设备通过 MQTT 主题 /board_ping 定期上报。
上报间隔默认 5 秒。
包含字段:设备标识、IP、温度、硬盘、内存、GPS、
通道列表、任务列表、TPU 算力等。
"""
BoardId: str = Field(..., description="当前盒子唯一标识")
BoardIp: str = Field(..., description="设备 IP 地址")
BoardPlatform: str = Field(..., description="当前设备硬件类型,如 'BM-BOX'")
BoardTemp: str = Field(..., description="当前设备芯片温度,如 '44 (C)'")
BoardType: str = Field(..., description="当前设备接入方式 WAN/4G")
GBClientId: str = Field(default="", description="GB28181 设备号,配置后透传")
GrantCode: int = Field(..., description="授权码")
GrantDesc: str = Field(..., description="授权描述,如 '已授权'")
HostDisk: HostDiskInfo = Field(..., description="当前设备硬盘情况")
HostMemory: list[float] = Field(..., description="当前设备内存使用 [已使用(kB), 总利用率]")
Key: str = Field(..., description="运行状态关键字,如 '运行中'")
Latitude: float = Field(default=0.0, description="GPS 纬度")
Longitude: float = Field(default=0.0, description="GPS 经度")
Malloc: int = Field(..., description="当前应用 VmRSS 信息 (kB)")
Medias: list[MediaInfo] = Field(default_factory=list, description="当前设备通道信息")
Pid: int = Field(..., description="本次应用 PID")
Se6ip: str = Field(default="", description="特定设备时透传主控端 IP")
Status: str = Field(..., description="设备状态,如 '在线'")
System: str = Field(..., description="当前设备系统版本")
Tasks: list[TaskInfo] = Field(default_factory=list, description="当前设备任务信息")
Time: int = Field(..., description="当前时间戳(毫秒)")
Tpu: list[TpuInfo] = Field(default_factory=list, description="当前设备算力资源使用情况")
Version: str = Field(..., description="软件版本")
angleCourse: float = Field(default=0.0, description="GPS 角度")
kSpeed: float = Field(default=0.0, description="GPS 速度 (km/h)")
nSpeed: float = Field(default=0.0, description="GPS 速度 (m/s)")
# ============================================================
# 设备状态摘要(API 响应用)
# ============================================================
class DeviceSummary(BaseModel):
"""设备状态摘要(用于列表 API 响应)"""
board_id: str = Field(..., description="设备唯一标识")
board_ip: str = Field(..., description="设备 IP")
board_platform: str = Field(..., description="硬件类型")
board_type: str = Field(..., description="接入方式")
board_temp: str = Field(..., description="芯片温度")
status: str = Field(..., description="设备状态")
version: str = Field(..., description="软件版本")
is_online: bool = Field(..., description="是否在线")
last_ping_time: Optional[str] = Field(default=None, description="最后心跳时间")
grant_code: int = Field(..., description="授权码")
grant_desc: str = Field(..., description="授权描述")
+31
View File
@@ -0,0 +1,31 @@
"""
AI Box Sophon Adapter — ORM 模型
SQLModel 定义设备心跳记录表。
"""
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class BoardHeartbeat(SQLModel, table=True):
"""设备心跳记录表"""
__tablename__ = "board_heartbeat"
id: Optional[int] = Field(default=None, primary_key=True)
board_id: str = Field(index=True, unique=True, description="设备唯一标识 (BoardId)")
board_ip: str = Field(default="", description="设备 IP")
board_platform: str = Field(default="", description="硬件类型")
board_temp: str = Field(default="", description="芯片温度")
board_type: str = Field(default="", description="接入方式 WAN/4G")
status: str = Field(default="未知", description="设备状态")
version: str = Field(default="", description="软件版本")
system_info: str = Field(default="", description="系统版本信息")
grant_code: int = Field(default=0, description="授权码")
grant_desc: str = Field(default="", description="授权描述")
last_ping_time: datetime = Field(default_factory=datetime.now, description="最后心跳时间")
ping_data: str = Field(default="{}", description="完整心跳数据 JSON")
is_online: bool = Field(default=True, description="是否在线")
created_at: datetime = Field(default_factory=datetime.now, description="首次上报时间")
updated_at: datetime = Field(default_factory=datetime.now, description="最后更新时间")
+34
View File
@@ -0,0 +1,34 @@
"""
自动生成的 Pydantic 模型 — aibox_sophon_adapter
来源: config/workers/aibox_sophon_adapter/
"""
from __future__ import annotations
from typing import Any, Optional
from pydantic import BaseModel, Field
class GetDeviceGetRequest(BaseModel):
"""查询单个设备详情 — 请求体"""
board_id: str = Field(...)
class GetDeviceGetResponse(BaseModel):
"""查询单个设备详情 — 响应体"""
board_id: str = Field(...)
is_online: bool = Field(...)
last_ping_time: Optional[str] = Field(default=None)
ping_data: Optional[dict[str, Any]] = Field(default=None)
class GetOfflineDevicesGetResponse(BaseModel):
"""查询离线/异常设备列表 — 响应体"""
offline_devices: list[dict] = Field(...)
total: int = Field(...)
class ListDevicesGetResponse(BaseModel):
"""查询所有设备状态 — 响应体"""
devices: list[dict] = Field(...)
total: int = Field(...)
# 所有响应模型的联合类型
AiboxSophonAdapterResponse = ListDevicesGetResponse | GetOfflineDevicesGetResponse | GetDeviceGetResponse
+14
View File
@@ -0,0 +1,14 @@
"""
路由导出 — aibox_sophon_adapter
"""
from .board_ping import board_ping
from .list_devices import list_devices
from .get_device import get_device
from .get_offline_devices import get_offline_devices
__all__ = [
"board_ping",
"list_devices",
"get_device",
"get_offline_devices",
]
+94
View File
@@ -0,0 +1,94 @@
"""
AI Box Sophon Adapter — MQTT 心跳上报处理器
监听 MQTT 主题 /board_ping(通过 MQTT Bridge 路由到 chongming/aibox_sophon_adapter/board_ping),
解析设备心跳数据并更新数据库记录。
"""
import json
import logging
from datetime import datetime
from chongming_worker import rpc_method
from ..models.board_models import BoardPingRequest
logger = logging.getLogger(__name__)
@rpc_method(
subject="aibox_sophon_adapter.board_ping",
method="MQTT",
summary="AI Box 设备心跳上报",
request_model=BoardPingRequest,
)
async def board_ping(request: BoardPingRequest, worker_id: str = "") -> dict:
"""
处理设备心跳上报
设备通过 MQTT 主题 /board_ping 定期(默认 5 秒)上报系统状态。
收到心跳后更新数据库中的设备记录,标记为在线。
"""
from ..global_var import db, heartbeat_timeout
board_id = request.BoardId
try:
async with db.get_session() as session:
from ..models.orm import BoardHeartbeat
from sqlalchemy import select
stmt = select(BoardHeartbeat).where(BoardHeartbeat.board_id == board_id)
result = await session.execute(stmt)
record: BoardHeartbeat | None = result.scalar_one_or_none()
now = datetime.now()
ping_json = request.model_dump_json()
if record is None:
# 新设备,创建记录
record = BoardHeartbeat(
board_id=board_id,
board_ip=request.BoardIp,
board_platform=request.BoardPlatform,
board_temp=request.BoardTemp,
board_type=request.BoardType,
status=request.Status,
version=request.Version,
system_info=request.System,
grant_code=request.GrantCode,
grant_desc=request.GrantDesc,
last_ping_time=now,
ping_data=ping_json,
is_online=True,
)
session.add(record)
logger.info(f"[Heartbeat] 新设备上线: {board_id} (IP: {request.BoardIp})")
else:
# 已有设备,更新心跳
record.board_ip = request.BoardIp
record.board_platform = request.BoardPlatform
record.board_temp = request.BoardTemp
record.board_type = request.BoardType
record.status = request.Status
record.version = request.Version
record.system_info = request.System
record.grant_code = request.GrantCode
record.grant_desc = request.GrantDesc
record.last_ping_time = now
record.ping_data = ping_json
record.is_online = True
record.updated_at = now
await session.commit()
logger.debug(
f"[Heartbeat] {board_id}: temp={request.BoardTemp}, "
f"disk={request.HostDisk.Used}/{request.HostDisk.Total} kB, "
f"mem={request.HostMemory[0]:.1f} kB ({request.HostMemory[1]*100:.1f}%), "
f"medias={len(request.Medias)}, tasks={len(request.Tasks)}"
)
except Exception as e:
logger.error(f"[Heartbeat] 处理心跳失败 (board_id={board_id}): {e}")
return {"error": str(e)}
return {"received": True, "board_id": board_id}
+63
View File
@@ -0,0 +1,63 @@
"""
查询单个设备详情 — GET /aibox/get_device?board_id=xxx
"""
import json
import logging
from datetime import datetime
from chongming_worker import rpc_method
logger = logging.getLogger(__name__)
@rpc_method(
subject="aibox_sophon_adapter.get_device",
method="GET",
summary="查询单个设备详情",
)
async def get_device(board_id: str, worker_id: str = "") -> dict:
"""根据 BoardId 查询设备的完整心跳数据和在线状态"""
from ..global_var import db, heartbeat_timeout
try:
async with db.get_session() as session:
from ..models.orm import BoardHeartbeat
from sqlalchemy import select
stmt = select(BoardHeartbeat).where(BoardHeartbeat.board_id == board_id)
result = await session.execute(stmt)
record: BoardHeartbeat | None = result.scalar_one_or_none()
if record is None:
return {"error": f"设备不存在: {board_id}"}
now = datetime.now()
elapsed = (now - record.last_ping_time).total_seconds()
is_online = elapsed <= heartbeat_timeout
ping_data = {}
try:
ping_data = json.loads(record.ping_data) if record.ping_data else {}
except json.JSONDecodeError:
pass
return {
"board_id": record.board_id,
"board_ip": record.board_ip,
"board_platform": record.board_platform,
"board_type": record.board_type,
"board_temp": record.board_temp,
"status": record.status,
"version": record.version,
"system_info": record.system_info,
"grant_code": record.grant_code,
"grant_desc": record.grant_desc,
"is_online": is_online,
"last_ping_time": record.last_ping_time.isoformat() if record.last_ping_time else None,
"elapsed_seconds": round(elapsed, 1),
"ping_data": ping_data,
}
except Exception as e:
logger.error(f"[get_device] 查询失败 (board_id={board_id}): {e}")
return {"error": str(e)}
@@ -0,0 +1,54 @@
"""
查询离线/异常设备 — GET /aibox/get_offline_devices
"""
import json
import logging
from datetime import datetime
from chongming_worker import rpc_method
logger = logging.getLogger(__name__)
@rpc_method(
subject="aibox_sophon_adapter.get_offline_devices",
method="GET",
summary="查询离线/异常设备列表",
)
async def get_offline_devices(worker_id: str = "") -> dict:
"""返回所有超过心跳超时时间未上报的设备列表"""
from ..global_var import db, heartbeat_timeout
offline_devices = []
try:
async with db.get_session() as session:
from ..models.orm import BoardHeartbeat
from sqlalchemy import select
stmt = select(BoardHeartbeat).order_by(BoardHeartbeat.last_ping_time.desc())
result = await session.execute(stmt)
records = result.scalars().all()
now = datetime.now()
for record in records:
elapsed = (now - record.last_ping_time).total_seconds()
is_online = elapsed <= heartbeat_timeout
if not is_online:
offline_devices.append({
"board_id": record.board_id,
"board_ip": record.board_ip,
"board_platform": record.board_platform,
"board_type": record.board_type,
"status": record.status,
"version": record.version,
"last_ping_time": record.last_ping_time.isoformat() if record.last_ping_time else None,
"elapsed_seconds": round(elapsed, 1),
"is_online": False,
})
except Exception as e:
logger.error(f"[get_offline_devices] 查询失败: {e}")
return {"offline_devices": [], "total": 0, "error": str(e)}
return {"offline_devices": offline_devices, "total": len(offline_devices)}
@@ -0,0 +1,69 @@
"""
查询所有设备状态 — GET /aibox/list_devices
"""
import json
import logging
from datetime import datetime
from chongming_worker import rpc_method
logger = logging.getLogger(__name__)
@rpc_method(
subject="aibox_sophon_adapter.list_devices",
method="GET",
summary="查询所有设备状态",
)
async def list_devices(worker_id: str = "") -> dict:
"""返回所有已上报过心跳的设备及其在线状态"""
from ..global_var import db, heartbeat_timeout
devices = []
try:
async with db.get_session() as session:
from ..models.orm import BoardHeartbeat
from sqlalchemy import select
stmt = select(BoardHeartbeat).order_by(BoardHeartbeat.updated_at.desc())
result = await session.execute(stmt)
records = result.scalars().all()
now = datetime.now()
for record in records:
# 检查是否超时
elapsed = (now - record.last_ping_time).total_seconds()
is_online = elapsed <= heartbeat_timeout
ping_data = {}
try:
ping_data = json.loads(record.ping_data) if record.ping_data else {}
except json.JSONDecodeError:
pass
devices.append({
"board_id": record.board_id,
"board_ip": record.board_ip,
"board_platform": record.board_platform,
"board_type": record.board_type,
"board_temp": record.board_temp,
"status": record.status,
"version": record.version,
"system_info": record.system_info,
"grant_code": record.grant_code,
"grant_desc": record.grant_desc,
"is_online": is_online,
"last_ping_time": record.last_ping_time.isoformat() if record.last_ping_time else None,
"elapsed_seconds": round(elapsed, 1),
"host_disk": ping_data.get("HostDisk", {}),
"host_memory": ping_data.get("HostMemory", []),
"medias_count": len(ping_data.get("Medias", [])),
"tasks_count": len(ping_data.get("Tasks", [])),
"tpu": ping_data.get("Tpu", []),
})
except Exception as e:
logger.error(f"[list_devices] 查询失败: {e}")
return {"devices": [], "total": 0, "error": str(e)}
return {"devices": devices, "total": len(devices)}