Compare commits

..
37 Commits
Author SHA1 Message Date
chakcy 4454393ad0 docs: 插件开发指南精简(路由选择合并为一句)并更新依赖声明分工说明 2026-10-13 17:29:26 +08:00
chakcy 6a8c63b9f2 modify ip 音柱分区域告警 2026-09-18 14:26:25 +08:00
chakcy e0211105bd modify 修复前端展示配置与实际插件配置不一致的bug 2026-09-17 15:34:34 +08:00
chakcy 293254c61a modify: IN2 DI 输入监听 2026-09-13 20:34:10 +08:00
chakcy d7636c4c3b modify: DI 输入监听 2026-09-11 16:55:54 +08:00
chakcy c139b0c1d3 modify: auth插件拆分,将token签发部分交给网关 2026-09-10 16:43:43 +08:00
chakcy 5ec90a5575 modify: ip音柱插件 2026-09-04 14:51:12 +08:00
chakcy 5ab68d6002 modify: 性能优化 2026-08-23 04:34:38 +08:00
chakcy 51aa93eefb modify: 流式输出 2026-08-21 14:48:05 +08:00
chakcy 5e5dee83df modify: cli命令更新 2026-08-20 16:51:00 +08:00
chakcy d6b385d2b1 modify: 数据库建表问题 2026-08-18 13:37:30 +08:00
chakcy faff9948b9 modify: 清理孤儿模型 AlarmReportPostResponse(契约校验对齐) 2026-08-14 18:06:58 +08:00
chakcy 7bfd14d23c modify: 移除 add_post 调试 print 2026-08-14 16:31:36 +08:00
chakcy f723a8441b modify: mqtt uri 支持 AIBOX_MQTT_URI 环境变量兜底 2026-08-14 16:27:31 +08:00
chakcy 4fa066432d modify: 统一插件风格 2026-08-14 16:04:39 +08:00
chakcy 56dd4f0ebb modify: 插件初始化 2026-08-12 10:24:32 +08:00
chakcy 3e1c568d04 modify: 接口转发 2026-08-11 14:47:19 +08:00
chakcy 74d115d2bb modify sophon 盒子获取 2026-08-10 11:29:54 +08:00
chakcy e9e029deaf modify mqtt 根主题问题 2026-08-10 10:44:52 +08:00
chakcy 1d768298e4 modify: mqtt 插件返回问题 2026-08-10 10:16:25 +08:00
chakcy ba09b6768e modify: internal_mqtt 测试 2026-08-07 16:40:48 +08:00
chakcy 54461cad18 modify: chongming-mqtt 应用 2026-08-07 14:28:39 +08:00
chakcy f1e6c52311 modify: 说明文档 2026-08-05 18:19:01 +08:00
chakcy ed8550ddc3 modify: 平台接口上报 2026-08-05 14:12:15 +08:00
chakcy 39b5ddd585 modify: aibox_sophon_adapter告警转发 2026-08-05 11:51:29 +08:00
chakcy 3663a3de83 modify: mqtt forward 日志 2026-08-03 18:08:33 +08:00
chakcy 96e8e366f5 modify: mqtt日志问题 2026-08-03 17:43:07 +08:00
chakcy 83ab65c10b modify: 插件配置读取修改 2026-08-03 16:56:56 +08:00
chakcy e43c25f5dc modify: mqtt心跳转发 2026-08-03 15:06:29 +08:00
chakcy 84addb71ff modify media_id 2026-08-03 10:35:08 +08:00
chakcy f024b6953e modify: aibox sophon adapter video 2026-08-03 09:16:23 +08:00
chakcy 5f6b8c5269 modify: 视频推流 2026-07-31 16:53:38 +08:00
chakcy e3265e40f0 modify: aiofile 文件覆盖问题 2026-07-28 16:19:27 +08:00
chakcy 48669eb2a9 modify: 上报接口 422 问题 2026-07-28 15:55:19 +08:00
chakcy e3552228b6 modify: 上报接口 422 问题 2026-07-28 15:52:21 +08:00
chakcy a12ceec221 modify: 上报接口 422 问题 2026-07-28 15:44:42 +08:00
chakcy 4d6ad2eae2 modify: 上报接口 422 问题 2026-07-28 15:35:45 +08:00
64 changed files with 4460 additions and 641 deletions
+244 -76
View File
@@ -1,60 +1,82 @@
# Worker 插件开发指南
每个子目录代表一个独立的 Worker 插件,封装特定领域的业务逻辑。插件通过 Zenoh RPC 对外提供服务,
HTTP 路由由框架根据 TOML 配置自动生成。
每个 `src/plugins/<name>/` 子目录是一个独立的 Worker 插件,封装特定领域的业务逻辑。插件通过 Zenoh RPC 对外提供服务,HTTP 路由由框架根据 TOML 配置自动生成。
---
## 目录结构约定
```text
plugin_name/
├── __init__.py # 入口:create_workers() 工厂 + cleanup()
├── global_var.py # 模块级全局状态(数据库连接、API 客户端等)
<plugin_name>/
├── __init__.py # 入口:Plugin 子类 + create_workers/cleanup 绑定
├── global_var.py # 模块级全局状态(Database、API client 等)
├── router/ # RPC 端点实现
│ ├── __init__.py # 导入所有端点模块,__all__ 导出
│ ├── __init__.py # 导入所有端点模块(`from . import *` 或显式导入)
│ └── <操作名>.py # 每个端点一个文件,使用 @rpc_method 装饰器
├── models/ # 数据模型
│ ├── __init__.py # 模型导出(自动生成,勿手动修改)
│ ├── pydantic.py # Pydantic 请求/响应模型(由 gen_models.py 自动生成)
│ ├── __init__.py # 模型导出(由 gen_models 自动生成,勿手动修改)
│ ├── pydantic.py # Pydantic 请求/响应模型(由 gen_models 自动生成)
│ └── <表名>.py # SQLModel ORM 模型(如需要数据库)
└── README.md # 插件说明文档
├── routes.py # 自定义 FastAPI 路由(可选,用于特殊端点)
└── README.md # 插件说明文档(可选)
```
---
## 已有插件概览
| 插件 | 特点 | 适合参考 |
| ------------------------ | -------------------------------------------- | ---------- |
| **example** | 最简示例,Plugin 基类 + Database + 生命周期 | 入门学习 |
| **auth** | SQLModel ORM + 管理员初始化 + 多进程幂等 | 数据库操作 |
| **doubao** | 外部 API 客户端(Ark/OpenAI)+ AICC 安全通道 | 外部集成 |
| **aibox_sophon_adapter** | 自定义 `APIRouter` + MQTT 客户端组件(`mqtt_clients.py`)+ 后台任务 | 高级模式 |
---
## 开发新插件:完整流程
### 第一步:创建目录结构
> **推荐**:直接用脚手架生成统一风格的插件骨架,无需手动创建目录:
>
> ```bash
> uv run gen_plugin <name> [operation ...]
> ```
>
> 以下为手动创建方式(了解结构时参考)。
```bash
mkdir -p src/plugins/<plugin_name>/router
mkdir -p src/plugins/<plugin_name>/models
touch src/plugins/<plugin_name>/__init__.py
touch src/plugins/<plugin_name>/global_var.py
touch src/plugins/<plugin_name>/router/__init__.py
mkdir -p src/plugins/<name>/router
mkdir -p src/plugins/<name>/models
touch src/plugins/<name>/__init__.py
touch src/plugins/<name>/global_var.py
touch src/plugins/<name>/router/__init__.py
```
### 第二步:注册插件
在 `config/workers/config.toml` 中添加:
在 `config/workers/<name>/config.toml` 中通过 `[workers]` 节注册(网关启动时自动扫描发现):
```toml
[[workers]]
type = "<plugin_name>"
module = "plugins.<plugin_name>"
factory = "create_workers"
cleanup = "cleanup"
count = 3
# config/workers/<name>/config.toml
[workers]
type = "<name>" # 插件类型(worker type)
module = "plugins.<name>" # Python 模块路径
factory = "create_workers" # 工厂函数名(Plugin 基类绑定的方法)
cleanup = "cleanup" # 清理函数名
count = 3 # Worker 副本数
# 运行时配置(展平后传给 create_workers 的 config 参数)
[workers.config]
type = "<plugin_name>"
# 自定义配置项...
data_dir = "./data"
```
> 旧式全局注册文件 `config/workers/config.toml`(`[[workers]]` 数组)仅作兜底兼容,新插件请使用各插件自己的 `config.toml`。
### 第三步:编写端点 TOML 配置
创建 `config/workers/<plugin_name>/config.toml`(分组配置):
创建 `config/workers/<name>/config.toml`(分组配置):
```toml
[worker]
@@ -62,9 +84,10 @@ defaults = { timeout = 3.0, auth_required = true, cacheable = false, internal =
[worker.registration]
router_prefix = "/<prefix>"
tags = ["<name>"]
```
创建每个操作的 `.toml` 文件,如 `config/workers/<plugin_name>/my_action.toml`:
创建每个操作的 `.toml` 文件,如 `config/workers/<name>/my_action.toml`:
```toml
[POST]
@@ -79,132 +102,277 @@ message = { type = "str", default = "" }
### 第四步:生成 Pydantic 模型
```bash
uv run python -m chongming.scripts.gen_models
uv run gen_models
```
这会自动在 `src/plugins/<plugin_name>/models/` 下生成 `pydantic.py` 和更新 `__init__.py`。
自动在 `src/plugins/<name>/models/` 下生成 `pydantic.py` 和 `__init__.py`。
### 第五步:实现 RPC 端点
`src/plugins/<plugin_name>/router/my_action.py`:
`src/plugins/<name>/router/my_action.py`:
```python
from chongming_worker.decorators import rpc_method
from ..models.pydantic import MyActionPostRequest, MyActionPostResponse
@rpc_method(
subject="<plugin_name>.my_action",
subject="<name>.my_action",
method="POST",
response_model=MyActionPostResponse,
)
async def my_action(request: MyActionPostRequest) -> MyActionPostResponse:
"""业务逻辑实现"""
"""业务逻辑"""
result = await do_something(request)
return MyActionPostResponse(result="success", message="done")
```
### 第六步:更新 router 导出
`src/plugins/<plugin_name>/router/__init__.py`:
`src/plugins/<name>/router/__init__.py`:
```python
from . import my_action
__all__ = ["my_action"]
```
### 第七步:实现工厂函数
### 第七步:实现插件入口(推荐 Plugin 基类)
`src/plugins/<plugin_name>/__init__.py`:
`src/plugins/<name>/__init__.py`(由 `gen_plugin` 脚手架生成):
```python
from chongming_worker.runtime import create_workers as _create_workers
import logging
def create_workers(session, config, count, worker_manager):
"""插件入口:初始化全局资源,启动 Worker"""
from . import router # 触发 @rpc_method 注册
from .global_var import set_db
from chongming_worker.plugin import Plugin
# 初始化数据库、客户端等
from chongming_database import Database
db = Database(config["data_dir"] / "mydb.db")
# await db.initialize() -- 在异步环境中
from . import router # noqa: F401 触发 @rpc_method 注册
# from .routes import router # 可选:自定义 FastAPI 路由(网关自动发现)
set_db(db)
_create_workers(session, config, count, worker_manager)
logger = logging.getLogger(__name__)
async def cleanup(worker_manager):
"""清理资源"""
from .global_var import db
if db:
await db.dispose()
class MyPlugin(Plugin):
type = "<name>"
db_name = "<name>.db" # 自动在 data_dir 下建 SQLite 库(不需要则置 None)
auto_bind = True # 首次初始化时自动 auto_bind_models("<name>")
async def on_init(self):
"""首次 create_workers 时调用一次:初始化资源、启动后台任务。"""
self.set_var("db", self.db) # 发布到 global_var 供业务代码读取
# self.spawn_task(self._background_loop())
async def on_cleanup(self):
"""cleanup 时调用:关闭自定义客户端等(后台任务与数据库由基类处理)。"""
# 暴露给 app lifespan 的模块级入口(bound method,签名与旧写法完全一致)
_plugin = MyPlugin()
create_workers = _plugin.create_workers
cleanup = _plugin.cleanup
```
基类自动处理:幂等(初始化锁)、统一日志、数据库建库/释放、后台任务取消、清理钩子、模型自动绑定、cleanup 时重置已发布的 global_var。详见 `utils/worker/README.md` 的「Plugin 基类」章节。
> **旧写法已废弃**:不依赖基类的传统 `create_workers` / `cleanup` 模块级函数写法仅框架层面保留兼容,现有插件(auth / doubao / aibox_sophon_adapter / example)均已迁移到 Plugin 基类,新插件请勿采用旧写法。
---
## 自定义 FastAPI 路由(高级)
当端点无法用 TOML 描述时(如文件上传),插件可暴露自定义 `APIRouter`。
`src/plugins/<name>/routes.py`:
```python
from fastapi import APIRouter, UploadFile, File, Form
router = APIRouter(prefix="/<prefix>", tags=["<name>"])
@router.post("/upload")
async def upload(
file: UploadFile = File(...),
description: str = Form(...),
):
# 处理文件上传
return {"status": "ok"}
```
在 `__init__.py` 中暴露 `router`:
```python
from .routes import router # 网关通过 getattr 自动发现
```
网关在 `load_workers()` 阶段自动检测并注册该 router,无需修改网关代码。
---
## 为现有插件添加新端点
1. 在 `config/workers/<plugin_name>/` 下创建 `<新操作>.toml`
2. 运行 `uv run python -m chongming.scripts.gen_models`
3. 在 `router/` 下创建 `<新操作>.py`,引用生成的模型
1. 创建 `config/workers/<name>/<new_op>.toml`
2. 运行 `uv run gen_models`
3. 创建 `router/<new_op>.py`,引用生成的模型
4. 更新 `router/__init__.py`
5. 重启服务
---
## 全局变量模式
## 共享对象(global_var)在两种路由中的使用
每个插件通过 `global_var.py` 管理模块级共享资源,避免跨请求的状态冲突:
### 数据流
```python
# global_var.py
db: "Database | None" = None
共享对象(Database、API client 等)在 `Plugin.on_init` 中初始化,通过 `set_var` 发布到 `global_var` 模块;两种路由的业务代码(模块级函数)在函数体内 `from .global_var import ...` 读取。
def set_db(database: "Database"):
global db
db = database
```mermaid
flowchart LR
P["Plugin.on_init<br/>self.db = ..."] -->|"self.set_var('db', self.db)"| GV["global_var 模块<br/>db / require()"]
R1["router/*.py<br/>@rpc_method 函数"] -->|"from ..global_var import require"| GV
R2["routes.py<br/>APIRouter 处理器"] -->|"from .global_var import db"| GV
```
在端点中直接引用:
> **为什么需要 `global_var.py`**:`@rpc_method` 函数和 APIRouter 处理器都是模块级函数,拿不到 Plugin 实例,只能通过模块级容器读取;`global_var.py` 独立成模块,可避免与 `__init__.py` 循环导入。`set_var()` 内部会调用 `global_var.set_global_var()`(白名单校验)完成发布。
### 注入:on_init → global_var
`src/plugins/<name>/__init__.py`:
```python
from ..global_var import db
class MyPlugin(Plugin):
type = "<name>"
db_name = "<name>.db"
@rpc_method(subject="plugin.action", method="GET", ...)
async def handler(request: RequestModel):
async def on_init(self):
self.set_var("db", self.db) # 发布到 global_var 模块
```
### 路由一:TOML 驱动标准路由(@rpc_method)
`router/<op>.py`(请求经 Zenoh → Worker Queryable → 调用函数):
```python
from chongming_worker.decorators import rpc_method
from ..models.pydantic import OpGetRequest, OpGetResponse
@rpc_method(subject="<name>.op", method="GET", response_model=OpGetResponse)
async def op(request: OpGetRequest) -> OpGetResponse:
from ..global_var import require
db = require("db") # 未初始化立即抛清晰错误
async with db.get_session() as session:
# 执行数据库操作
...
return OpGetResponse(...)
```
### 路由二:插件自定义路由(APIRouter)
`routes.py`(FastAPI 直接调用,适合 multipart 上传等 TOML 无法描述的端点):
```python
from fastapi import APIRouter, UploadFile, File
router = APIRouter(prefix="/<name>", tags=["<name>"])
@router.post("/upload")
async def upload(file: UploadFile = File(...)):
from .global_var import db
if db is None: # 容忍未初始化,优雅降级
return {"error": "db 未初始化"}
async with db.get_session() as session:
...
return {"ok": True}
```
在 `__init__.py` 顶部暴露给网关:`from .routes import router`(网关通过 `getattr(module, "router")` 自动发现)。
**怎么选**:标准 JSON / query 端点(参数与响应可被 Pydantic 模型描述)用路由一
`@rpc_method`;multipart 上传、需手动解析报文等无法用 TOML 描述的端点用路由二 `APIRouter`。
### 经验
- **在函数体内 import**(不要放文件顶部):模块 import 时 `create_workers` 尚未执行、`global_var` 还是空;请求到达时变量才注入完成。
- **两种读取风格**:`require("db")`(必须有,未初始化立即报错)vs `from .global_var import db` + `if db is None`(允许优雅降级)。
- **参考实现**:`aibox_sophon_adapter/routes.py` 的 `_save_alarm_record`(自定义路由中 `from .global_var import db` + `async with db.get_session()`)。
---
## 装饰器说明
## @rpc_method 装饰器
```python
@rpc_method(
subject="<plugin>.<operation>", # Zenoh 主题(必需)
method="GET", # HTTP 方法:GET 或 POST
method="GET", # HTTP 方法:GET / POST / MQTT
response_model=ResponseCls, # Pydantic 响应模型类
)
async def handler(request: RequestCls) -> ResponseCls:
...
```
- **GET 端点**:参数通过 URL query string 传递,在 TOML 中用 `params` 定义
- **POST 端点**:参数通过 JSON body 传递,自动绑定到 Pydantic 请求模型
- **internal 端点**:设置 `internal = true` 后,不注册 HTTP 路由,仅可通过 Zenoh RPC 内部调用
| method | 参数来源 | 请求绑定 |
| ------ | ------------------ | ---------------------------- |
| `GET` | URL query string | TOML `params` 定义 |
| `POST` | JSON body | 自动绑定到 Pydantic 请求模型 |
| `MQTT` | MQTT payload JSON | 同 POST |
**请求信封协议**:网关统一把请求组装为 `{"meta": {"method", "user_info"}, "data": {...}}`
发给 Worker;运行时按 `meta.method` 分派到对应 `@rpc_method`,`data` 经请求模型校验后
传给处理器。MQTT 客户端发布消息时需按信封格式(见主 README「请求负载格式」),
MQTT 专用端点要显式 `"meta": {"method": "MQTT"}`。
- 设置 `internal = true` 后,端点不注册 HTTP 路由,仅可通过 Zenoh RPC 或 MQTT 内部调用
- MQTT 方法返回 `None` 时,Bridge 自动跳过响应发布
---
## 插件专属依赖(第三方包)
插件用了网关本体不 import 的第三方包(如 doubao 的 `openai` / `volcenginesdkarkruntime`),
除在根 `pyproject.toml` 的 dependencies 里安装外,还要在**根目录 `plugins_deps.toml`** 里
按插件分节声明,打包收集清单(`--hidden-import` / `--include-module`)才会带上它:
```toml
# 多个插件共用(顶层 imports)
imports = ["httpx"]
[<插件名>]
imports = ["openai", "volcenginesdkarkruntime"]
```
- **声明位置**:inline 插件在 `.mbank` 内 import,打包静态分析看不到,因此除根
`pyproject.toml`(venv 安装)外,插件专属依赖还必须写进根目录 `plugins_deps.toml`,
打包收集清单(`--hidden-import` / `--include-module`)才会带上它
- 改为 external 进程插件后不需要本文件:依赖随插件项目 `workers/<name>/pyproject.toml` 自包含
- **一键迁移**:`uv run migrate_plugin <name>` 把 `src/plugins/<name>/` 迁移为
`workers/<name>/` 进程插件项目(代码/配置/依赖一并迁移,`plugins_deps.toml`
对应节自动注释)。注意:挂载了自定义 FastAPI 路由(`APIRouter`)或导入了网关包的
插件会被拦截拒绝迁移(`--force` 可跳过),前者需先改成 TOML `@rpc_method` 端点
---
## 启动契约校验
服务启动时(`load_workers` 之后、注册路由之前),网关自动核对三方一致性:
| 检查项 | 策略 |
| ---------------------------- | --------------------------- |
| TOML HTTP 路由缺 `@rpc_method` | **硬失败**,启动直接终止 |
| `[MQTT]` / `internal` 路由缺注册 | 警告日志 |
| 已注册方法无 TOML 路由 | 警告日志(反向漂移检测) |
| params/response_model 缺生成模型 | 警告日志(gen_models 过期) |
| 生成模型未被任何路由引用 | 警告日志(孤儿模型) |
新增端点后如果启动报错「缺少对应的 @rpc_method 注册」,检查 TOML 的 `subject`/方法节
与 `router/<op>.py` 中的 `@rpc_method(subject=..., method=...)` 是否一致。
---
## 命名约定
| 概念 | 约定 | 示例 |
| ----------------- | --------------- | ----------------------------------- |
| 插件名 | 小写下划线 | `doubao`, `auth`, `example` |
| 操作名 | 小写下划线 | `analyze`, `add`, `change_password` |
| Zenoh subject | `<插件>.<操作>` | `example.add`, `doubao.analyze` |
| TOML 文件名 | 与操作名一致 | `analyze.toml`, `add.toml` |
| Python 路由文件名 | 与操作名一致 | `analyze.py`, `add.py` |
| ------------- | ------------------------------ | ---------------------------- |
| 插件名 | 小写下划线 | `aibox_sophon_adapter` |
| 操作名 | 小写下划线 | `board_ping`, `list_devices` |
| Zenoh subject | `<插件>.<操作>` | `example.add` |
| TOML 文件名 | 与操作名一致 | `add.toml` |
| Python 文件名 | 与操作名一致 | `add.py` |
| 模型类名 | `<操作><方法>Request/Response` | `AddGetRequest` |
---
+421
View File
@@ -0,0 +1,421 @@
# aibox_sophon_adapter — AI Box Sophon 设备适配器
基于 **MQTT + Zenoh RPC** 的 AI Box(Sophon 算力盒子)设备接入适配器。
盒子通过 MQTT 主题 `/board_ping` 周期上报系统状态心跳,插件负责解析、持久化、对外提供
查询 API,并在设备离线时自动告警;同时支持告警视频上传、违规事件上报与外部平台转发。
> 插件类型:`aibox_sophon_adapter` | Zenoh subject 前缀:`aibox_sophon_adapter.*` | 路由前缀:`/aibox_sophon_adapter`
---
## 核心功能
| # | 功能 | 说明 |
| --- | -------------------- | ------------------------------------------------------------------------------------------ |
| 1 | **MQTT 心跳上报** | 通过网关 MQTT Bridge 订阅 `box/aibox_sophon/board_ping`,解析设备系统参数并持久化到数据库 |
| 2 | **设备状态查询** | 提供 HTTP API 查询设备在线状态、详情、离线设备列表 |
| 3 | **离线检测告警** | 后台任务周期扫描,超过 `heartbeat.timeout` 未上报心跳的设备标记为离线并输出告警日志 |
| 4 | **视频流管理** | 心跳中状态为「正常」的通道自动通过 MediaMTX 拉取 RTSP 视频流(`StreamManager`) |
| 5 | **视频上传接口** | 盒子告警时通过 HTTP FORM-DATA 上传录制视频,返回唯一 `VideoId` |
| 6 | **告警上报接口** | 盒子违规事件 JSON 报文字动解析入库(`alarm_record` 表) |
| 7 | **告警查询接口** | 告警列表(分页/筛选)与单条告警详情查询 |
| 8 | **豆包 AI 分析** | 告警图片可交由 `doubao` 插件判定是否为误报(可配置开关) |
| 9 | **IP 音柱 TTS 播报** | 告警时按违规类型调用 `ip_sound_column` 插件播放 TTS 语音(可配置开关/音量/文案) |
| 10 | **外部 MQTT 转发** | 心跳数据转发到外部 MQTT Broker(`mqtt_forward`) |
| 11 | **外部 MQTT 订阅** | 直接订阅外部 Broker,实现 `edge_app_controller` 请求-响应协议(`mqtt_subscribe`) |
| 12 | **告警转发外部平台** | 先上传视频再上报告警到外部开放平台(`alarm_forward`) |
| 13 | **IN2 任务启停开关** | 监听盒子 IN2 数字输入:有电启动全部算法任务,断电停止全部任务(`task_switch`) |
---
## 架构与数据流
### 心跳上报链路
```mermaid
sequenceDiagram
participant Box as AI Box (Sophon)
participant Broker as MQTT Broker (网关 Bridge)
participant Zenoh as Zenoh RPC
participant Plugin as aibox_sophon_adapter
participant DB as SQLite
participant MTX as MediaMTX
Box->>Broker: publish box/aibox_sophon/board_ping
Broker->>Zenoh: 路由为 aibox_sophon_adapter.board_ping
Zenoh->>Plugin: 调用 board_ping(request)
Plugin->>DB: upsert board_heartbeat 记录
Plugin->>MTX: 状态正常通道 start_stream(RTSP)
Plugin->>Broker: 心跳转发外部 MQTT(可选)
```
### 告警上报链路
```mermaid
sequenceDiagram
participant Box as AI Box
participant GW as 网关 HTTP
participant Plugin as aibox_sophon_adapter
participant DB as SQLite
participant Doubao as doubao 插件
participant Ext as 外部开放平台
Box->>GW: POST /api/v1/aibox_sophon_adapter/video_upload (FORM-DATA)
GW->>Plugin: 保存视频 → 返回 VideoId
Box->>GW: POST /api/v1/aibox_sophon_adapter/alarm_report (JSON)
GW->>Plugin: 手动 Pydantic 验证报文
Plugin->>Doubao: 豆包 AI 分析(可关闭)
Plugin->>DB: 写入 alarm_record(按 AlarmId 去重 + 裁剪)
Plugin->>Ext: 上传视频 + 转发告警(可选)
```
### 外部 MQTT 请求-响应(edge_app_controller)
```mermaid
sequenceDiagram
participant C as 外部客户端
participant Broker as 外部 Broker
participant Plugin as aibox_sophon_adapter
C->>Broker: publish /edge_app_controller {"command": "..."}
Broker->>Plugin: 订阅回调 edge_app_controller(payload, topic)
Plugin->>Plugin: 按 command 分发到注册表 _COMMANDS
Plugin-->>Broker: publish /edge_app_controller_reply {"code":0,"msg":"ok","data":{...}}
```
---
## 目录结构
```text
src/plugins/aibox_sophon_adapter/
├── __init__.py # 插件入口:AiboxSophonAdapterPlugin(Plugin 基类)
│ # - on_init: DB / StreamManager / MQTT 客户端 / 全局变量 / 后台任务
│ # - on_cleanup: 流控制器与 MQTT 客户端关闭(db/后台任务由基类处理)
├── mqtt_clients.py # MQTT 客户端能力组件(从 __init__ 抽取,统一"启动失败降级为 None"模式)
│ # - start_mqtt_forward / start_mqtt_subscribe / start_internal_mqtt
│ # - build_subscribe_routes() 构建订阅路由
├── global_var.py # 模块级全局变量(db / stream_manager / mqtt_* / 各配置)
├── routes.py # 自定义 FastAPI 路由(video_upload / video / alarm_report)
├── edge_app_controller.py # 外部 MQTT 请求-响应处理器(command 注册表分发)
├── task_switch.py # IN2 数字输入控制任务启停(复用 diwatch 的 IN2 通道)
├── alarm_trim.py # 告警记录清理(超限裁剪 + 删除关联视频)
├── diwatch/ # DI 数字输入监听库(SE5/SE9 的 IN1/IN2,独立 CLI:python3 -m diwatch;task_switch 复用其 IN2 通道)
├── in1.py / in11.py # diwatch 前身:IN1 事件脚本 / IN2 轮询脚本(保留备用)
├── router/ # Zenoh RPC 端点(@rpc_method)
│ ├── __init__.py # 导出全部端点模块
│ ├── board_ping.py # MQTT 心跳上报
│ ├── list_devices.py # 查询所有设备状态
│ ├── get_device.py # 查询单个设备详情
│ ├── get_offline_devices.py # 查询离线/异常设备
│ ├── list_alarms.py # 查询告警记录列表
│ └── get_alarm.py # 查询单条告警详情
└── models/ # 数据模型
├── board_models.py # 心跳数据结构(手动维护,嵌套过深无法自动生成)
├── alarm_models.py # 告警上报请求/响应模型
├── video_models.py # 视频上传响应模型
├── orm.py # SQLModel ORM(board_heartbeat / alarm_record)
├── pydantic.py # gen_models 自动生成(如存在)
└── __init__.py # 模型导出
```
---
## 快速开始
### 1. 注册插件
插件已注册于 `config/workers/aibox_sophon_adapter/config.toml`(新架构由框架自动扫描):
```toml
[workers]
type = "aibox_sophon_adapter"
module = "plugins.aibox_sophon_adapter"
factory = "create_workers"
cleanup = "cleanup"
count = 1
```
### 2. 启动服务
```bash
uv run serve
```
启动时插件会自动:初始化 SQLite → 连接 MediaMTX → 启动 MQTT 转发/订阅/内部客户端 →
创建 Worker 副本 → 启动离线检查后台任务。
### 3. 验证
```bash
# 查看设备列表
curl "http://127.0.0.1:8000/api/v1/aibox_sophon_adapter/list_devices"
```
---
## HTTP API 接口
### 自动生成 RPC 端点(前缀 `/api/v1/aibox_sophon_adapter`)
| 方法 | 路径 | 说明 | 参数 |
| ---- | ---------------------- | --------------------- | -------------------------------------------------------------- |
| GET | `/list_devices` | 查询所有设备状态 | 无 |
| GET | `/get_device` | 查询单个设备详情 | `board_id: str` |
| GET | `/get_offline_devices` | 查询离线/异常设备列表 | 无 |
| GET | `/list_alarms` | 告警列表(分页/筛选) | `board_id: str`、`summary: str`、`page: int`、`page_size: int` |
| GET | `/get_alarm` | 查询单条告警详情 | `alarm_id: str` |
> `board_ping` 为 MQTT 端点,不产生 HTTP 路由。
### 自定义 FastAPI 路由(`routes.py`,同样挂在 `/api/v1` 下)
| 方法 | 路径 | 说明 |
| ---- | ------------------- | --------------------------------------------------------------------------- |
| POST | `/video_upload` | 视频上传(multipart/form-data),返回 `{"Result": {...}, "VideoId": "..."}` |
| GET | `/video/{video_id}` | 视频下载/流式播放(`Content-Disposition: inline`) |
| POST | `/alarm_report` | 告警上报(JSON),返回 `{"Result": {"Code": 0, "Desc": ""}}` |
#### `video_upload` 表单字段
| 字段 | 必填 | 说明 |
| ------------- | ---- | ---------------------- |
| `BoardIp` | 是 | 盒子网络地址 |
| `BoardId` | 是 | 盒子唯一标识 |
| `TaskSession` | 是 | 所属任务标识 |
| `GBDeviceId` | 否 | 国标设备号(无则空串) |
| `GBTaskChnId` | 否 | 国标通道号(无则空串) |
| `Video` | 是 | 视频文件 |
存储路径约定:`{upload_root}/aibox_sophon_adapter/{video_id}/{filename}`(流式写入 + MD5 校验)。
#### `alarm_report` 处理流程
1. 手动 Pydantic 验证基础字段(`alarm_models.AlarmReportRequest`,失败记录完整请求体并返回 422)
2. **豆包 AI 分析**:判定为 `False` 时忽略告警(返回 Code 0 + "忽略告警")
3. **IP 音柱 TTS 播报**:按 `Summary` 匹配文案调用 `ip_sound_column.tts` 播放语音(失败仅记录日志,不阻断)
4. **写库**:按 `AlarmId/UniqueId` 去重写入 `alarm_record`,超限自动裁剪(`alarm_trim`)
5. **外部转发**:先上传视频替换 `VideoFile` 为外部 `VideoId`,再上报告警
> 盒子仅当 **HTTP 200 且 `Result.Code` 非 0** 时才认为上报失败。
---
## MQTT 主题
### 网关 MQTT Bridge 订阅(`[[workers.mqtt_topics]]`)
| 主题 | 目标 Zenoh subject | 说明 |
| ----------------------------- | --------------------------------- | ------------ |
| `box/aibox_sophon/board_ping` | `aibox_sophon_adapter.board_ping` | 盒子心跳上报 |
> 盒子只设根主题 `box`(非 `chongming` 保留命名空间,避免被网关 Bridge 按 Worker RPC 拦截);
> 子路径固定 `aibox_sophon/...`。
### 外部 MQTT 订阅(`[workers.config.mqtt_subscribe]`,插件直连外部 Broker)
| 主题 | 处理函数 | 回复主题 |
| ---------------------- | --------------------- | ---------------------------- |
| `/edge_app_controller` | `edge_app_controller` | `/edge_app_controller_reply` |
### 内部 MQTT 请求-响应(`[workers.config.internal_mqtt]`)
`edge_app_controller` 的 `alg_media_fetch` / `alg_task_fetch` 命令通过内部客户端向
任意应用进程发起请求-响应:
- 请求主题:`box/aibox_sophon/edge_app_controller`
- 回复主题:`box/aibox_sophon/edge_app_controller_reply`
> 切勿使用 `chongming/{插件}/{操作}` —— 网关 Bridge 订阅 `chongming/+/+` 会拦截 3 级主题并按
> Worker RPC 路由,而 `edge_app_controller` 不是 `@rpc_method`,会导致
> `worker_manager` 报 "Worker ... 无回复,尝试重试"。
---
## edge_app_controller 请求-响应协议
订阅 `/edge_app_controller`,回复发布到 `/edge_app_controller_reply`。
**请求**(JSON):`{"command": "<命令名>", ...业务字段}`;兼容 `{"Event": "/alg_media_fetch", ...}`
(`Event` 取最后一段作为命令)。
**回复**(JSON):`{"code": 0, "msg": "ok", "data": {...}}`
| code | 含义 |
| ---- | ----------------- |
| 0 | 成功 |
| -1 | 缺少 command 字段 |
| -2 | 未知命令 |
| -3 | 命令执行异常 |
**内置命令注册表(`_COMMANDS`)**:
| command | 说明 | 是否回复 |
| ----------------- | ---------------------------------------- | ---------- |
| `ping` | 存活探测,返回服务信息 | 是 |
| `ack` | 确认命令,仅记录日志 | 否(静默) |
| `query_status` | 复用 `list_devices` 返回设备在线状态列表 | 是 |
| `alg_media_fetch` | 按 `BoardId` 返回最近心跳的通道/媒体信息 | 是 |
| `alg_task_fetch` | 按 `BoardId` 返回最近心跳的算法任务信息 | 是 |
> 命令处理函数返回 `None` 表示只处理、不回复(静默命令)。新增命令只需在 `_COMMANDS`
> 注册表登记一个 `async handler(payload) -> data` 函数。
---
## 数据库表
数据库默认为 SQLite 文件 `{data_dir}/aibox_sophon_adapter.db`(默认 `./data/`);
配置了 `[workers.config.database].url` 时优先走 PostgreSQL(当前 config.toml 即此模式),
由 `chongming_worker.Plugin` 基类统一初始化。
### `board_heartbeat` — 设备心跳记录
| 字段 | 说明 |
| -------------------------------------------- | ---------------------------------- |
| `board_id` | 设备唯一标识(唯一索引) |
| `board_ip` / `board_platform` / `board_type` | IP / 硬件类型 / 接入方式(WAN/4G) |
| `board_temp` | 芯片温度 |
| `status` / `version` / `system_info` | 状态 / 软件版本 / 系统版本 |
| `grant_code` / `grant_desc` | 授权码 / 授权描述 |
| `last_ping_time` / `is_online` | 最后心跳时间 / 是否在线 |
| `ping_data` | 完整心跳数据 JSON |
### `alarm_record` — 告警记录
| 字段 | 说明 |
| -------------------------------------------- | ----------------------------------------------- |
| `alarm_id` | 告警唯一标识(唯一索引) |
| `board_id` / `board_ip` | 设备标识 |
| `summary` | 违规类型(NoHelmet / Intrude / PeopleCross 等) |
| `task_session` / `media_name` / `video_file` | 任务号 / 通道名 / 关联视频标识 |
| `alarm_time` / `alarm_timestamp` | 告警时间 / 时间戳(微秒) |
| `alarm_data` | 完整告警报文 JSON |
---
## 配置详解
配置文件:`config/workers/aibox_sophon_adapter/config.toml`
### 插件注册(`[workers]` / `[worker]` / `[worker.registration]`)
```toml
[workers]
type = "aibox_sophon_adapter"
module = "plugins.aibox_sophon_adapter"
factory = "create_workers"
cleanup = "cleanup"
count = 1 # Worker 副本数
[worker]
defaults = { method = "GET", timeout = 30, auth_required = false, cacheable = false, internal = false }
[worker.registration]
router_prefix = "/aibox_sophon_adapter"
tags = ["aibox_sophon_adapter", "sophon", "adapter"]
```
### 运行时配置(`[workers.config]`)
| 配置节 | 字段 | 默认值 | 说明 |
| ----------------- | ------------------------------------------------------ | ----------------------------------- | ------------------------------ |
| (根) | `data_dir` | `./data` | 数据目录(数据库 + 上传文件) |
| (根) | `mediamtx_addr` | `rtsp://127.0.0.1/channel` | MediaMTX 地址 |
| (根) | `link_path` | `/data/bm-app` | 视频流软链接路径 |
| `heartbeat` | `interval` | `5` | 设备上报间隔(秒) |
| `heartbeat` | `timeout` | `15` | 超时阈值(秒),超过即视为离线 |
| `heartbeat` | `check_interval` | `10` | 服务端检查离线设备的间隔(秒) |
| `mqtt_forward` | `enabled` / `uri` / `topic` | false / 空 / `/board_ping` | 心跳转发到外部 Broker |
| `mqtt_subscribe` | `enabled` / `uri` / `topics` | false / 空 / 空 | 直连外部 Broker 订阅(见上) |
| `internal_mqtt` | `enabled` / `uri` / `request_topic` / `response_topic` | false / `mqtt://127.0.0.1:1883` / … | 内部请求-响应客户端 |
| `alarm_retention` | `max_records` | `0` | 最多保留告警记录数(0 不限制) |
| `doubao_analysis` | `enabled` | `true` | 是否启用豆包 AI 分析告警图片 |
| `ip_sound_column` | `enabled` / `volume` / `tts` | `true` / `50` / `{}` | 告警时 IP 音柱 TTS 播报(`tts` 为 Summary → 播报文案映射,未命中用默认文案) |
| `task_switch` | `enabled` / `api_base` / `start_command` / `stop_command` / `poll_interval` / `debounce` / `active_low` | true / `http://127.0.0.1` / 1 / 0 / 0.05 / 3 / false | IN2 数字输入控制任务启停(见下节) |
| `alarm_forward` | `enabled` / `url` / `video_upload_url` / `timeout` | false / … / … / 10.0 | 告警转发外部开放平台 |
> 示例:`mqtt_forward.uri = "mqtt://admin:Mixc%402021@192.168.1.6:1883"` —— 密码中特殊字符
> 需 URL 编码(`%40` 即 `@`)。
---
## 心跳与离线机制
- 设备按 `heartbeat.interval`(默认 5 秒)通过 `box/aibox_sophon/board_ping` 上报心跳。
- `board_ping` 处理器:新设备建记录 / 已有设备更新心跳并置为在线;状态「正常」的通道
自动通过 MediaMTX 启动 RTSP 拉流;可选转发外部 MQTT。
- 后台任务 `_check_offline_devices` 每 `heartbeat.check_interval`(默认 10 秒)扫描一次,
将超过 `heartbeat.timeout`(默认 15 秒)未上报的设备标记为离线并输出告警日志。
---
## IN2 任务启停开关(task_switch)
监听盒子 IN2 数字输入(diwatch `SysfsBackend` 轮询 `gpio_status`,含抗抖),
电平变化时通过盒子本地 HTTP 接口控制**全部**算法任务:
- IN2 **有电**(电平 1)→ 启动全部任务(`ControlCommand = start_command`,默认 1)
- IN2 **断电**(电平 0)→ 停止全部任务(`ControlCommand = stop_command`,默认 0)
控制流程:`POST {api_base}/api/alg_task_fetch`(查全部任务的 `AlgTaskSession`)→
逐个 `POST {api_base}/api/alg_task_control`(启停控制,按返回 `Result.Code` 判定成败并记日志)。
### IN2 后端自适应(SE5 / SE9)
由 `diwatch.make_in2_backend` 自动选择,优先级:显式 `device` → sysfs 状态文件(SE9)→ gpio-keys event 设备(SE5)→ 放弃启动。
| 板卡 | 后端 | 说明 |
| ---- | ---- | ---- |
| SE9 | `SysfsBackend` | 轮询 `/sys/bus/platform/devices/gpio-in-2/gpio_status`,`poll_interval` / `debounce` 生效 |
| SE5 | `EventBackend` | 无 gpio-in-2 节点,IN2 与 IN1 同走 gpio-keys 事件设备(`event1`),键码 `key`(默认 `KEY_DOWN=108`) |
SE5 首次部署需确认两件事:
1. **event 读权限**:`linaro` 用户运行 diwatch 报 `Permission denied: '/dev/input/event1'` 时,
执行 `sudo usermod -aG input linaro` 并重新登录(服务以 root 运行则不受影响)。
2. **IN2 实际键码**:操作 IN2 的同时运行 `python3 -m diwatch detect -t 5`,输出里的 `code=` 即键码;
写入 `task_switch.key`(或 CLI `in2 -k <键码>`)。若 detect 无任何输出,说明该 SE5 的 IN2
未接入 gpio-keys,需查 `sudo cat /sys/kernel/debug/gpio` 确认接线。
行为约定:
1. **只响应电平跳变**,不按插件启动时的初始电平动作(避免插件重启时误触发全量启停)。
2. DI 轮询跑在独立线程,HTTP 控制调度回事件循环并加锁串行,快速抖动不会并发穿插。
3. 找不到 IN2 状态文件(非盒子环境)时降级为不启动,仅输出警告,不影响插件其他功能。
---
## 开发指南
### 新增 RPC 端点
1. 在 `config/workers/aibox_sophon_adapter/` 下创建 `<操作>.toml`(参考 `list_devices.toml`)
2. 运行 `uv run gen_models` 生成模型
3. 在 `src/plugins/aibox_sophon_adapter/router/` 下创建 `<操作>.py`,使用 `@rpc_method`
4. 更新 `router/__init__.py` 的导入与 `__all__`
5. 重启服务
### 新增 edge_app_controller 命令
在 `edge_app_controller.py` 中定义 `async def _cmd_xxx(payload) -> data`,并在 `_COMMANDS`
注册表中登记即可。返回 `None` 表示静默处理(不回复)。
### 修改配置
编辑 `config/workers/aibox_sophon_adapter/config.toml` 后重启服务即可生效,无需重新生成模型
(运行时配置均在 `AiboxSophonAdapterPlugin.on_init` 中读取;MQTT 客户端启动逻辑见 `mqtt_clients.py`)。
---
## 注意事项
1. **不要使用 `chongming/` 命名空间**:盒子 MQTT 根主题固定为 `box`,子路径固定
`aibox_sophon/...`,否则会被网关 Bridge 拦截并按 Worker RPC 路由。
2. **`edge_app_controller` 不是 `@rpc_method`**:它只通过外部 MQTT 订阅触发,Worker 上
无对应 Zenoh Queryable。
3. **数据库模型**(`models/orm.py`)与心跳结构(`models/board_models.py`)为手动维护,
`board_models.py` 因嵌套过深无法由 `gen_models` 自动生成。
4. **视频文件清理**:告警记录裁剪时会同步删除 `{upload_root}/aibox_sophon_adapter/{video_id}/`
目录下的关联视频。
5. **日志**:使用标准 `logging.getLogger(__name__)`,核心已配置 ANSI 颜色格式化器。
+150 -59
View File
@@ -2,7 +2,7 @@
AI Box Sophon Adapter 插件
基于 MQTT 的 AI Box 设备心跳适配器,实现:
1. 通过 MQTT 主题 /board_ping 接收设备心跳上报
1. 通过 MQTT 主题接收设备心跳上报
2. 解析设备系统参数(温度、硬盘、内存、通道、任务、TPU 等)
3. 持久化心跳数据到 SQLite 数据库
4. 提供 HTTP API 查询设备在线状态和详情
@@ -10,31 +10,36 @@ AI Box Sophon Adapter 插件
6. 视频上传接口 — 盒子告警时录制视频上传(FORM-DATA)
7. 告警上报接口 — 违规事件 JSON 报文字动解析入库
8. 告警查询接口 — 告警列表/详情查询
9. IN2 任务启停开关 — DI 输入有电启动全部任务,断电停止全部任务
心跳超时机制:
- 设备按配置间隔(默认 5 秒)上报心跳
- 服务端每 check_interval 秒检查一次
- 超过 heartbeat_timeout 秒未收到心跳的设备标记为离线
生命周期: 基于 Plugin 基类统一管理(数据库/后台任务/清理钩子由基类处理),
MQTT 客户端启动块已抽取到 mqtt_clients 能力组件。
"""
import asyncio
import logging
import shutil
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 chongming_worker.plugin import Plugin
from .router import *
from .routes import router # 自定义 FastAPI 路由(网关自动发现)
from .router import * # noqa: F401,F403 触发 @rpc_method 注册
from .routes import router # noqa: F401 自定义 FastAPI 路由(网关通过包属性自动发现)
from .edge_app_controller import edge_app_controller # noqa: F401
from .mqtt_clients import start_mqtt_forward, start_mqtt_subscribe, start_internal_mqtt
# 模块加载时即注册 SQLModel 元数据 —— 基类 _bootstrap 建表(create_all)只创建
# 已导入的表,而 global_var 的导入是懒加载的(on_init 才执行),若拖到建表之后
# 会导致全新库缺少 board_heartbeat / alarm_record 表(no such table)。
from .models.orm import BoardHeartbeat, AlarmRecord # noqa: F401
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):
"""
@@ -55,7 +60,7 @@ async def _check_offline_devices(interval: float):
from .models.orm import BoardHeartbeat
from sqlalchemy import select
stmt = select(BoardHeartbeat).where(BoardHeartbeat.is_online == True) # type: ignore
stmt = select(BoardHeartbeat).where(BoardHeartbeat.is_online) # type: ignore
result = await session.execute(stmt)
records = result.scalars().all()
@@ -80,68 +85,154 @@ async def _check_offline_devices(interval: float):
logger.error(f"[OfflineChecker] 检查异常: {e}")
async def create_workers(session, config, count=1, worker_manager=None):
"""
插件入口 — 初始化数据库和全局资源,创建 Worker 副本
class AiboxSophonAdapterPlugin(Plugin):
"""AI Box 适配插件 — 统一生命周期。"""
由 FastAPI lifespan 在启动时调用。
"""
type = "aibox_sophon_adapter"
db_name = "aibox_sophon_adapter.db" # 自动在 data_dir 下建 SQLite 库
auto_bind = True # 首次初始化时自动 auto_bind_models
async def on_init(self):
"""首次 create_workers 时调用一次:初始化数据库客户端、流控制器、后台任务。"""
config = self.config
from .global_var import set_global_var, heartbeat_timeout as _default_timeout
from chongming_database import Database
from chongming_stream_manager import StreamManager
# 读取心跳配置
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)
# 所有运行时配置均从 [workers.config] 及其子节读取
data_dir = Path(config.get("data_dir", "./data"))
mediamtx_addr = config.get("mediamtx_addr", "rtsp://127.0.0.1:8554")
link_path = config.get("link_path", "/data/bm-app")
# 设定全局变量
global heartbeat_timeout
heartbeat_timeout = hb_timeout
# 心跳配置
heartbeat = config.get("heartbeat", {})
hb_interval = heartbeat.get("interval", 5)
hb_timeout = heartbeat.get("timeout", _default_timeout)
check_interval = heartbeat.get("check_interval", 10)
# 初始化数据库
data_dir = Path(config.get("data_dir", "./data")) if isinstance(config, dict) else Path("./data")
db_path = data_dir / "aibox_sophon_adapter.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
# 告警保留配置
alarm_retention = config.get("alarm_retention", {})
alarm_max = alarm_retention.get("max_records", 0)
db = Database(db_path)
await db.initialize()
# 豆包 AI 分析开关
doubao_cfg = config.get("doubao_analysis", {})
doubao_enabled = doubao_cfg.get("enabled", True)
# ip 音柱 TTS 播放开关
ip_sound_column_cfg = config.get("ip_sound_column", {})
ip_sound_column_enabled = ip_sound_column_cfg.get("enabled", True)
ip_sound_column_volume = ip_sound_column_cfg.get("volume", 50) # 播放音量 0-100
ip_sound_column_tts = ip_sound_column_cfg.get("tts", {}) # 播放内容(key: Summary,value: tts 文本)
# 告警转发配置
alarm_fwd_cfg = config.get("alarm_forward", {})
alarm_fwd_enabled = alarm_fwd_cfg.get("enabled", False)
alarm_fwd_url = alarm_fwd_cfg.get("url", "http://dg.datagoo.cn:60014/open-api/event-alarm/receive")
alarm_fwd_video_url = alarm_fwd_cfg.get("video_upload_url", "http://dg.datagoo.cn:60014/open-api/event-alarm/receiveVideoUpload")
alarm_fwd_timeout = alarm_fwd_cfg.get("timeout", 10.0)
# IN2 数字输入控制任务启停
switch_cfg = config.get("task_switch", {})
# 数据库已由基类初始化(data_dir/aibox_sophon_adapter.db)
db = self.db
# 初始化视频流控制器
stream_manager = StreamManager(mediamtx_addr)
# upload 根目录(用于清理告警关联视频)
upload_root_path = str(data_dir / "uploads")
# MQTT 客户端能力组件(启动失败降级为 None,仅警告)
mqtt_fwd_client = await start_mqtt_forward(config.get("mqtt_forward", {}))
mqtt_sub_client = await start_mqtt_subscribe(config.get("mqtt_subscribe", {}))
int_mqtt_client, int_req_topic, int_resp_topic = await start_internal_mqtt(
config.get("internal_mqtt", {})
)
set_global_var("db", db)
set_global_var("heartbeat_timeout", hb_timeout)
set_global_var("heartbeat_interval", hb_interval)
set_global_var("stream_manager", stream_manager)
set_global_var("link_path", link_path)
set_global_var("mqtt_forward", mqtt_fwd_client)
set_global_var("mqtt_subscribe", mqtt_sub_client)
set_global_var("internal_mqtt", int_mqtt_client)
set_global_var("internal_request_topic", int_req_topic)
set_global_var("internal_response_topic", int_resp_topic)
set_global_var("upload_root", upload_root_path)
set_global_var("alarm_max_records", alarm_max)
set_global_var("doubao_analysis_enabled", doubao_enabled)
set_global_var("ip_sound_column_enabled", ip_sound_column_enabled)
set_global_var("ip_sound_column_volume", ip_sound_column_volume)
set_global_var("ip_sound_column_tts", ip_sound_column_tts)
set_global_var("alarm_forward_enabled", alarm_fwd_enabled)
set_global_var("alarm_forward_url", alarm_fwd_url)
set_global_var("alarm_forward_video_url", alarm_fwd_video_url)
set_global_var("alarm_forward_timeout", alarm_fwd_timeout)
# 探测 ffmpeg(RTSP 拉流依赖);缺失时心跳仍正常入库,仅跳过拉流
ffmpeg_available = shutil.which("ffmpeg") is not None
set_global_var("ffmpeg_available", ffmpeg_available)
if not ffmpeg_available:
logger.warning(
"[aibox_sophon_adapter] 未检测到 ffmpeg,设备正常通道的 RTSP 拉流将跳过"
)
# IN2 任务启停开关(DI 有电 → 启动全部任务;断电 → 停止全部任务)
# 找不到 IN2 状态文件(非盒子环境)时降级为 None,仅警告
task_switch = None
if switch_cfg.get("enabled", False):
from .task_switch import In2TaskSwitch
task_switch = In2TaskSwitch(
api_base=switch_cfg.get("api_base", "http://127.0.0.1"),
start_command=switch_cfg.get("start_command", 1),
stop_command=switch_cfg.get("stop_command", 0),
poll_interval=switch_cfg.get("poll_interval", 0.05),
debounce=switch_cfg.get("debounce", 3),
active_low=switch_cfg.get("active_low", False),
device=switch_cfg.get("device"),
key=switch_cfg.get("key"),
)
if not task_switch.start(asyncio.get_running_loop()):
task_switch = None
set_global_var("task_switch", task_switch)
logger.info(
f"[aibox_sophon_adapter] 已初始化: "
"[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))
# 后台任务由基类跟踪,cleanup 时自动取消
self.spawn_task(_check_offline_devices(check_interval))
# 创建 Worker 副本
return _create_workers(session, config, count, worker_manager)
async def on_cleanup(self):
"""cleanup 时调用:关闭流控制器、外部 MQTT 客户端与任务启停开关(db 与后台任务由基类处理)。"""
from .global_var import (
stream_manager, mqtt_forward, mqtt_subscribe, internal_mqtt,
task_switch,
)
if stream_manager is not None:
await stream_manager.stop_all()
if mqtt_forward is not None:
await mqtt_forward.stop()
if mqtt_subscribe is not None:
await mqtt_subscribe.stop()
if internal_mqtt is not None:
await internal_mqtt.stop()
if task_switch is not None:
task_switch.stop()
logger.info("[aibox_sophon_adapter] 清理完成")
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} 清理完毕")
# 暴露给 app lifespan 的模块级入口(bound method,签名与旧写法完全一致)
# 注意: 实例名必须为 plugin(router 已被 routes.router APIRouter 占用)
plugin = AiboxSophonAdapterPlugin()
create_workers = plugin.create_workers
cleanup = plugin.cleanup
+94
View File
@@ -0,0 +1,94 @@
"""
AI Box Sophon Adapter — 告警记录清理
当告警记录超过配置的 max_records 时,自动清理最早的记录及其关联的视频文件。
"""
import logging
import os
import shutil
from sqlalchemy import select, func, asc
from .models.orm import AlarmRecord
logger = logging.getLogger(__name__)
async def trim_alarm_records(db_session, upload_root: str, max_records: int) -> int:
"""
裁剪告警记录,保留最新的 max_records 条
删除超出限制的最早记录,同时清理关联的视频文件目录。
Args:
db_session: SQLAlchemy 异步 session
upload_root: 上传文件根目录(如 ./data/uploads)
max_records: 最大保留条数,<= 0 表示不限制
Returns:
实际删除的记录数
"""
if max_records <= 0:
return 0
# 查询当前总记录数
count_result = await db_session.execute(
select(func.count()).select_from(AlarmRecord)
)
total = count_result.scalar() or 0
if total <= max_records:
return 0
# 超出数量
overflow = total - max_records
# 查询最早的 overflow 条记录(按 created_at 升序)
stmt = (
select(AlarmRecord)
.order_by(asc(AlarmRecord.created_at)) # type: ignore
.limit(overflow)
)
result = await db_session.execute(stmt)
old_records = result.scalars().all()
deleted_count = 0
for record in old_records:
# 清理关联的视频文件目录
if record.video_file:
_delete_video_dir(upload_root, record.video_file)
await db_session.delete(record)
deleted_count += 1
await db_session.commit()
if deleted_count > 0:
logger.info(
f"[AlarmTrim] 已清理 {deleted_count} 条告警记录 "
f"(保留最新 {max_records} 条,原共 {total} 条)"
)
return deleted_count
def _delete_video_dir(upload_root: str, video_id: str) -> None:
"""
删除告警关联的视频文件目录
视频存储路径: {upload_root}/aibox_sophon_adapter/{video_id}/
Args:
upload_root: 上传根目录
video_id: 视频唯一标识(VideoId)
"""
video_dir = os.path.join(upload_root, "aibox_sophon_adapter", video_id)
if not os.path.isdir(video_dir):
return
try:
shutil.rmtree(video_dir)
logger.debug(f"[AlarmTrim] 已删除视频目录: {video_dir}")
except OSError as e:
logger.warning(f"[AlarmTrim] 删除视频目录失败 [{video_dir}]: {e}")
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""diwatch — 统一 DI 输入监听库,适配 SE5 / SE9。
对外主要 API:
from diwatch import DIChannel, EventBackend, SysfsBackend, make_in1_backend
# IN1(自动选后端)
ch = DIChannel("IN1", backend=make_in1_backend(), active_low=True)
ch.on_press = lambda ts: print("有电", ts)
ch.on_release = lambda ts: print("断电", ts)
ch.run()
# IN2(sysfs 轮询)
ch2 = DIChannel("IN2",
backend=SysfsBackend("/sys/bus/platform/devices/gpio-in-2/gpio_status"),
active_low=False)
ch2.on_change = lambda old, new, ts: print(f"{old}->{new}", ts)
ch2.run(duration=10)
"""
from .core import DIChannel, fmt_dur, now_str
from .backends import (
EventBackend, SysfsBackend, make_backend,
make_in1_backend, find_status_file,
)
from .devicetree import derive_gpio
__all__ = [
"DIChannel", "fmt_dur", "now_str",
"EventBackend", "SysfsBackend", "make_backend",
"make_in1_backend", "find_status_file", "derive_gpio",
]
__version__ = "1.1.0"
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env python3
import sys
from .cli import main
if __name__ == "__main__":
sys.exit(main())
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""DI 通道的底层读取后端。
每个后端只负责:open / close / read。
电平语义(0=低,1=高)由上层 DIChannel 统一处理。
后端类型:
- EventBackend:/dev/input/eventN,事件驱动,可 poll。SE5 的 IN1。
- SysfsBackend:/sys/.../gpio_status 或 /dev/tv_gpioin_N,轮询 + 抗抖。
SE9 的 IN1、所有设备的 IN2。
"""
import errno
import glob
import os
import struct
import time
from .config import (
EV_KEY, VALUE_RELEASE, VALUE_PRESS, VALUE_REPEAT,
DEFAULT_EVENT_DEVICE, GPIO_KEYS_NAME, DEFAULT_IN2_KEY,
DEBOUNCE, DEFAULT_INTERVAL,
find_status_file,
)
EVENT_STRUCT = struct.Struct("llHHi") # aarch64 / x86_64 都是 24 字节
# ---------------------------------------------------------------------------
# 基类
# ---------------------------------------------------------------------------
class Backend:
"""后端接口。"""
can_poll = False
def open(self):
raise NotImplementedError
def close(self):
raise NotImplementedError
def read(self):
"""非阻塞读一次,返回本批事件列表 [(level, ts), ...];无事件返回 []。
level 是原始电平 0/1,尚未做 active_low 翻转。
同一批数据里的多个事件必须全部返回,不能只留最后一个,
否则快速抖动(一次 read 里按下+释放)会丢失中间跳变。
"""
raise NotImplementedError
@property
def name(self):
raise NotImplementedError
# ---------------------------------------------------------------------------
# /dev/input/eventN 后端(SE5 的 IN1)
# ---------------------------------------------------------------------------
class EventBackend(Backend):
"""基于 input 事件设备,事件驱动。
原始 value 语义:
VALUE_PRESS(1) -> 硬件“按下”
VALUE_RELEASE(0) -> 硬件“释放”
本后端返回原始 value,极性翻转留给 DIChannel。
"""
can_poll = True
def __init__(self, device=DEFAULT_EVENT_DEVICE, key=None,
find_by_name=GPIO_KEYS_NAME):
self.device = device
self.key = key
self.find_by_name = find_by_name
self.fd = None
self.buf = b""
# ---- 自动查找 ----
@staticmethod
def find_event_device(name=GPIO_KEYS_NAME):
"""按设备名找 eventN,找不到返回 None。"""
for path in sorted(glob.glob("/dev/input/event*")):
ev = path.rsplit("/", 1)[-1]
namefile = f"/sys/class/input/{ev}/device/name"
try:
with open(namefile) as f:
if f.read().strip() == name:
return path
except OSError:
continue
return None
def resolve_device(self):
if not self.device or self.device == "auto":
return self.find_event_device(self.find_by_name) or DEFAULT_EVENT_DEVICE
return self.device
@property
def name(self):
dev = self.device or "(auto)"
return f"event:{dev}"
# ---- 生命周期 ----
def open(self):
if self.fd is None:
dev = self.resolve_device()
if not dev or not os.path.exists(dev):
raise FileNotFoundError(
errno.ENOENT,
f"event 设备不存在:{dev}",
dev,
)
self.device = dev
self.fd = os.open(dev, os.O_RDONLY | os.O_NONBLOCK)
def close(self):
if self.fd is not None:
try:
os.close(self.fd)
finally:
self.fd = None
def fileno(self):
return self.fd
# ---- 读取 ----
def read(self):
"""返回 [(level, ts), ...];只汇报关心的 key,批内事件按序全部返回。"""
if self.fd is None:
return []
try:
data = os.read(self.fd, EVENT_STRUCT.size * 64)
except OSError:
return []
self.buf += data
events = []
while len(self.buf) >= EVENT_STRUCT.size:
chunk = self.buf[:EVENT_STRUCT.size]
self.buf = self.buf[EVENT_STRUCT.size:]
sec, usec, etype, code, value = EVENT_STRUCT.unpack(chunk)
if etype != EV_KEY:
continue
if self.key is not None and code != self.key:
continue
if value == VALUE_REPEAT:
continue
if value not in (VALUE_RELEASE, VALUE_PRESS):
continue
ts = sec + usec / 1e6
events.append((value, ts))
return events
# ---------------------------------------------------------------------------
# sysfs 状态文件后端(SE9 的 IN1、所有 IN2)
# ---------------------------------------------------------------------------
class SysfsBackend(Backend):
"""基于 /sys/.../gpio_status 或 /dev/tv_gpioin_N,轮询采样 + 抗抖。"""
can_poll = False
def __init__(self, path, debounce=DEBOUNCE, interval=DEFAULT_INTERVAL):
self.path = path
self.debounce = max(1, int(debounce))
self.interval = interval
self._cand = None
self._cand_n = 0
self._reported = None # 上次上报的稳定值(跳变只报一次)
@property
def name(self):
return f"sysfs:{self.path}"
def open(self):
if not os.access(self.path, os.R_OK):
raise FileNotFoundError(
errno.ENOENT,
f"状态文件不可读:{self.path}",
self.path,
)
def close(self):
pass
# ---- 原始电平 ----
def read_raw(self):
try:
with open(self.path, "r") as f:
v = f.read()
except OSError:
return None
v = v.strip().strip("\t\r\n ")
return v if v in ("0", "1") else None
# ---- 带抗抖的读取 ----
def read(self):
"""返回 [(level, ts)];抗抖未确认或电平未变化时返回 []。
连续 debounce 次采样一致才确认稳定值;确认值与上次上报不同
(真实跳变)才上报一次,避免稳态期间每个抗抖周期重复产出事件。
"""
v = self.read_raw()
if v is None:
return []
ts = time.time()
if self._cand is None or v == self._cand:
self._cand = v
self._cand_n += 1
else:
self._cand = v
self._cand_n = 1
if self._cand_n < self.debounce:
return []
confirmed = int(v)
if self._reported == confirmed:
return []
self._reported = confirmed
return [(confirmed, ts)]
# ---------------------------------------------------------------------------
# 工厂
# ---------------------------------------------------------------------------
def make_backend(kind: str, **kwargs):
"""kind = 'event' | 'sysfs'。"""
if kind == "event":
return EventBackend(**kwargs)
if kind == "sysfs":
return SysfsBackend(**kwargs)
raise ValueError(f"未知后端类型:{kind}")
def make_in1_backend(device=None, key=None, in_index=1):
"""自动为 IN1 选择后端,适配 SE5 / SE9。
优先级:
1) 显式 device(非 auto) -> EventBackend
2) 自动查找 gpio-keys 的 event 设备 -> EventBackend
3) 查找 gpio-in-<in_index> 的状态文件 -> SysfsBackend
4) 都找不到 -> 返回 EventBackend(open 时抛出可诊断的错误)
"""
# 1) 显式指定
if device and device != "auto":
return EventBackend(device, key=key)
# 2) 自动查找 gpio-keys
ev = EventBackend.find_event_device(GPIO_KEYS_NAME)
if ev:
return EventBackend(ev, key=key)
# 3) 回退到 sysfs 状态文件
status = find_status_file(in_index)
if status:
return SysfsBackend(status)
# 4) 都找不到,返回一个 open 时会报错的 EventBackend
return EventBackend(DEFAULT_EVENT_DEVICE, key=key)
def make_in2_backend(device=None, key=None, in_index=2,
interval=DEFAULT_INTERVAL, debounce=DEBOUNCE):
"""自动为 IN2 选择后端:优先 sysfs(SE9),回退 gpio-keys event(SE5)。
SE5 无 gpio-in-2 状态文件,IN2 与 IN1 同走 gpio-keys 事件设备(不同键码)。
优先级:
1) 显式 device(event 设备路径) -> EventBackend
2) 查找 gpio-in-<in_index> 的状态文件 -> SysfsBackend
3) 自动查找 gpio-keys 的 event 设备 -> EventBackend(key 缺省 DEFAULT_IN2_KEY)
4) 都找不到 -> 返回 None(由调用方给出可诊断错误)
"""
# 1) 显式指定 event 设备
if device and device != "auto":
return EventBackend(device, key=key if key is not None else DEFAULT_IN2_KEY)
# 2) sysfs 状态文件(SE9)
status = find_status_file(in_index)
if status:
return SysfsBackend(status, debounce=debounce, interval=interval)
# 3) 回退到 gpio-keys 事件设备(SE5)
ev = EventBackend.find_event_device(GPIO_KEYS_NAME)
if ev:
return EventBackend(ev, key=key if key is not None else DEFAULT_IN2_KEY)
# 4) 都找不到
return None
def find_event_device(name=GPIO_KEYS_NAME):
"""模块级别别名,等价于 EventBackend.find_event_device."""
return EventBackend.find_event_device(name)
__all__ = [
"Backend",
"EventBackend",
"SysfsBackend",
"make_backend",
"make_in1_backend",
"make_in2_backend",
"find_status_file",
"find_event_device",
]
+486
View File
@@ -0,0 +1,486 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""diwatch 统一命令行入口,适配 SE5 / SE9。
用法示例:
# IN1(自动选后端:SE5 走 event,SE9 走 sysfs)
python3 -m diwatch in1
python3 -m diwatch in1 -t 10
python3 -m diwatch in1 --press-command 'logger "IN1 on"' \
--release-command 'logger "IN1 off"'
python3 -m diwatch in1 -d /dev/input/event0 # 手动指定 event
python3 -m diwatch in1 --no-active-low # 不翻转极性
# IN2(优先 sysfs/SE9;SE5 无 gpio-in-2 时回退 gpio-keys event)
python3 -m diwatch in2
python3 -m diwatch in2 0.005
python3 -m diwatch in2 --check
python3 -m diwatch in2 -k 108 # 指定 event 键码
python3 -m diwatch detect # 扫描全部 event 设备,查 IN1/IN2 实际键码
# 同时监听
python3 -m diwatch both
"""
import argparse
import datetime
import os
import signal
import sys
import time
from pathlib import Path
from .backends import (
EventBackend, SysfsBackend, EVENT_STRUCT,
make_in1_backend, make_in2_backend,
find_status_file, find_event_device,
)
from .core import DIChannel, fmt_dur
from .devicetree import derive_gpio
from .config import (
DEFAULT_EVENT_DEVICE, KEY_UP, DEFAULT_INTERVAL,
IN1_INDEX, IN2_INDEX, IN2_GPIO_NUM, IN2_GPIOCHIP,
STATUS_CANDIDATES, DEFAULT_IN2_KEY,
)
# ---------------------------------------------------------------------------
# 构造通道
# ---------------------------------------------------------------------------
def build_in1(args, log):
backend = make_in1_backend(
device=args.device,
key=args.key,
in_index=IN1_INDEX,
)
ch = DIChannel(
"IN1", backend,
active_low=args.active_low,
log=log,
)
if args.press_command:
ch.on_press = lambda ts: run_cmd(args.press_command)
if args.release_command:
ch.on_release = lambda ts: run_cmd(args.release_command)
return ch
def build_in2(args, log):
backend = make_in2_backend(
device=getattr(args, "device", None),
key=getattr(args, "key", None),
in_index=IN2_INDEX,
interval=args.interval or DEFAULT_INTERVAL,
)
if backend is None:
return None
ch = DIChannel(
"IN2", backend,
active_low=getattr(args, "active_low", False),
log=log,
)
if getattr(args, "press_command", None):
ch.on_press = lambda ts: run_cmd(args.press_command)
if getattr(args, "release_command", None):
ch.on_release = lambda ts: run_cmd(args.release_command)
return ch
# ---------------------------------------------------------------------------
# 子命令
# ---------------------------------------------------------------------------
def cmd_in1(args):
log = make_logger("IN1")
ch = build_in1(args, log)
install_signal(ch)
# 手动 open,便于给出友好错误
try:
ch.open()
except FileNotFoundError as e:
_print_open_error("IN1", e)
return 1
except OSError as e:
print(f"错误:打开 IN1 失败:{e}", file=sys.stderr)
return 1
try:
print(f"监听 {ch.backend.name} key={args.key} "
f"active_low={args.active_low},Ctrl-C 退出", flush=True)
ch.run(duration=args.duration or None)
except KeyboardInterrupt:
pass
finally:
ch.close()
print_summary([ch])
return 0
def cmd_in2(args):
log = make_logger("IN2")
ch = build_in2(args, log)
if ch is None:
print(f"错误:找不到 IN{IN2_INDEX} 的可用后端。已尝试:", file=sys.stderr)
for c in STATUS_CANDIDATES[IN2_INDEX]:
print(f" sysfs: {c}", file=sys.stderr)
print(f" event: 自动查找 gpio-keys(默认键码 {DEFAULT_IN2_KEY})", file=sys.stderr)
print("排查:ls /sys/bus/platform/devices/ | grep gpio-in", file=sys.stderr)
print(" ls -l /dev/input/event*", file=sys.stderr)
print(f" python3 -m diwatch detect # 查 IN2 实际键码", file=sys.stderr)
return 1
if args.check:
return do_check_in2(ch)
install_signal(ch)
try:
ch.open()
except FileNotFoundError as e:
_print_open_error("IN2", e)
return 1
except OSError as e:
print(f"错误:打开 IN2 失败:{e}", file=sys.stderr)
print("若为 Permission denied,把当前用户加入 input 组:"
"sudo usermod -aG input $USER 后重新登录", file=sys.stderr)
return 1
try:
print("=" * 60)
print(f"IN{IN2_INDEX} 监听开始 {datetime.datetime.now():%Y-%m-%d %H:%M:%S}")
if isinstance(ch.backend, SysfsBackend):
print(f" IN{IN2_INDEX} = GPIO {IN2_GPIO_NUM} = "
f"gpiochip{IN2_GPIOCHIP} 第 {IN2_GPIO_NUM - IN2_GPIOCHIP} 线")
print(f" 状态文件:{ch.backend.path}")
print(f" 轮询间隔:{ch.backend.interval}s "
f"抗抖:连续 {ch.backend.debounce} 次采样一致")
else:
print(f" 后端:{ch.backend.name} 键码:{ch.backend.key}")
print(" 提示:若操作 IN2 无事件输出,运行 python3 -m diwatch detect 查实际键码")
print("=" * 60)
ch.run(duration=args.duration or None)
except KeyboardInterrupt:
pass
finally:
ch.close()
print_summary([ch])
return 0
def cmd_both(args):
log = make_logger("BOTH")
ch1 = build_in1(args, log)
ch2 = build_in2(args, log)
if ch2 is None:
print(f"错误:找不到 IN{IN2_INDEX} 状态文件。", file=sys.stderr)
return 1
install_signal(ch1, ch2)
# 逐个 open,便于报错
for name, ch in (("IN1", ch1), ("IN2", ch2)):
try:
ch.open()
except FileNotFoundError as e:
_print_open_error(name, e)
return 1
except OSError as e:
print(f"错误:打开 {name} 失败:{e}", file=sys.stderr)
return 1
try:
print("=" * 60)
print(f"同时监听 IN1 + IN2 {datetime.datetime.now():%Y-%m-%d %H:%M:%S}")
print(f" IN1 -> {ch1.backend.name}")
print(f" IN2 -> {ch2.backend.name}")
print("=" * 60)
import threading
duration = args.duration or None
t1 = threading.Thread(target=ch1.run, kwargs={"duration": duration})
t2 = threading.Thread(target=ch2.run, kwargs={"duration": duration})
t1.start(); t2.start()
while t1.is_alive() or t2.is_alive():
t1.join(0.5); t2.join(0.5)
except KeyboardInterrupt:
ch1.request_stop(); ch2.request_stop()
finally:
ch1.close(); ch2.close()
print_summary([ch1, ch2])
return 0
# ---------------------------------------------------------------------------
# 辅助
# ---------------------------------------------------------------------------
def make_logger(tag):
def _log(msg):
print(f"[{tag}] {msg}", flush=True)
return _log
def run_cmd(cmd):
import subprocess
subprocess.Popen(cmd, shell=True)
def install_signal(*channels):
def _handler(*_a):
for c in channels:
c.request_stop()
signal.signal(signal.SIGINT, _handler)
signal.signal(signal.SIGTERM, _handler)
def _print_open_error(name, exc):
print(f"错误:{name} 不可用:{getattr(exc, 'filename', exc)}",
file=sys.stderr)
print("", file=sys.stderr)
print("排查步骤:", file=sys.stderr)
print(" 1) 查看 event 设备及其名字:", file=sys.stderr)
print(" for f in /dev/input/event*; do \\", file=sys.stderr)
print(" echo \"$f -> $(cat /sys/class/input/$(basename $f)/device/name 2>/dev/null)\"; \\",
file=sys.stderr)
print(" done", file=sys.stderr)
print(" 2) 查看 sysfs 状态文件:", file=sys.stderr)
print(" ls /sys/bus/platform/devices/ | grep gpio-in", file=sys.stderr)
print(" 3) 手动指定 event 设备:", file=sys.stderr)
print(" python3 -m diwatch in1 -d /dev/input/eventN", file=sys.stderr)
print(" 4) 若 SE9 无 /dev/input/,本工具会自动回退到 "
"gpio-in-1/gpio_status", file=sys.stderr)
def print_summary(channels):
print()
print("=" * 60)
print(f"汇总 {datetime.datetime.now():%Y-%m-%d %H:%M:%S}")
for ch in channels:
s = ch.summary()
print(f" [{s['name']}] 跳变 {s['count']} 次 "
f"低 {fmt_dur(s['ms_low'])} 高 {fmt_dur(s['ms_high'])} "
f"结束电平 {s['level']}")
print("=" * 60)
# ---------------------------------------------------------------------------
# --check for IN2
# ---------------------------------------------------------------------------
def do_check_in2(ch):
print(f"=== IN{IN2_INDEX} 自检 ===")
print()
be = ch.backend
if not isinstance(be, SysfsBackend):
print(f"后端:{be.name} 键码:{be.key}")
print("event 后端无初始电平可读;操作一次 IN2 应出现 value=1(按下/有电)与 0(释放/断电)。")
print(f"若无任何输出,说明键码不是 {be.key},用 python3 -m diwatch detect 查实际键码。")
return 0
print(f"状态文件:{be.path}")
print()
print("--- 从设备树现场复算 ---")
ok = True
for n in (1, 2):
got = derive_gpio(n)
if got is None:
print(f" IN{n}:复算失败(设备树不可读或节点缺失,SE5 无此节点属正常)")
ok = False
continue
gpio_num, label, pin = got
expect = 481 + n
if gpio_num == expect:
print(f" IN{n} -> GPIO {gpio_num} ✓ 与预期一致")
else:
print(f" IN{n} -> GPIO {gpio_num} ✗ 预期 {expect}")
ok = False
print()
if ok:
print(f"结论:IN{IN2_INDEX} = GPIO {IN2_GPIO_NUM},推导成立。")
else:
print("结论:推导与实测不符(SE9 正常成立;SE5 走 event 后端,此节可忽略)。")
print()
print("--- 当前电平 ---")
raw = be.read_raw()
if raw is not None:
print(f" gpio_status = {raw} (全局 GPIO {IN2_GPIO_NUM})")
print(" 注:待机时读到 0 是正常的。")
else:
print(f" 读取失败:{be.path} 内容不是 0/1。")
return 0
# ---------------------------------------------------------------------------
# detect:扫描全部 event 设备,实时打印按键事件(确认 IN1/IN2 键码)
# ---------------------------------------------------------------------------
KEY_NAMES = {
1: "KEY_ESC", 28: "KEY_ENTER", 103: "KEY_UP", 108: "KEY_DOWN",
105: "KEY_LEFT", 106: "KEY_RIGHT", 114: "KEY_VOLUMEDOWN",
115: "KEY_VOLUMEUP", 116: "KEY_POWER",
}
def cmd_detect(args):
"""扫描可读的 /dev/input/event* 设备,监听 duration 秒并打印所有 EV_KEY 事件。"""
import glob
import select
devices = sorted(glob.glob("/dev/input/event*"))
if not devices:
print("错误:/dev/input/ 下没有 event 设备", file=sys.stderr)
return 1
names = {} # fd -> (device, 名称)
poller = select.poll()
for dev in devices:
try:
fd = os.open(dev, os.O_RDONLY | os.O_NONBLOCK)
except PermissionError:
print(f" 跳过 {dev}(权限不足;用 sudo 运行,或把用户加入 input 组)",
file=sys.stderr)
continue
except OSError as e:
print(f" 跳过 {dev}({e})", file=sys.stderr)
continue
name = ""
try:
with open(f"/sys/class/input/{os.path.basename(dev)}/device/name") as f:
name = f.read().strip()
except OSError:
pass
names[fd] = (dev, name)
poller.register(fd, select.POLLIN)
if not names:
print("错误:没有可读的 event 设备(Permission denied 时用 sudo 运行)", file=sys.stderr)
return 1
duration = args.duration or 5.0
print("=" * 60)
print(f"监听 {len(names)} 个 event 设备 {duration:g} 秒,请现在操作 IN1 / IN2 ...")
for fd, (dev, name) in names.items():
print(f" {dev} [{name}]")
print("=" * 60)
deadline = time.time() + duration
count = 0
try:
while True:
remain_ms = int((deadline - time.time()) * 1000)
if remain_ms <= 0:
break
if not poller.poll(min(remain_ms, 200)):
continue
for fd in list(names):
try:
data = os.read(fd, EVENT_STRUCT.size * 64)
except OSError:
continue
if not data:
continue
dev, _name = names[fd]
for off in range(0, len(data) - EVENT_STRUCT.size + 1, EVENT_STRUCT.size):
_sec, _usec, etype, code, value = EVENT_STRUCT.unpack_from(data, off)
if etype != 0x01: # 只看 EV_KEY
continue
if value == 2: # 长按 repeat 太吵
continue
count += 1
key = KEY_NAMES.get(code, "?")
val = "按下" if value == 1 else "释放"
print(f" {datetime.datetime.now():%H:%M:%S.%f} {dev} "
f"code={code:<4} ({key}) {val}")
except KeyboardInterrupt:
pass
finally:
for fd in names:
try:
os.close(fd)
except OSError:
pass
print()
if count == 0:
print("未捕获任何按键事件。若已操作 IN2 仍无输出,说明 SE5 的 IN2 不走 event 设备,")
print("请检查:ls /sys/bus/platform/devices/ | grep -i gpio;"
"sudo cat /sys/kernel/debug/gpio")
else:
print(f"共捕获 {count} 个按键事件。IN2 对应的 code 即为 -k/--key(或 task_switch.key)应填的键码。")
return 0
# ---------------------------------------------------------------------------
# 入口
# ---------------------------------------------------------------------------
def build_parser():
p = argparse.ArgumentParser(
prog="diwatch",
description="统一 DI 输入监听(适配 SE5 / SE9)",
)
sub = p.add_subparsers(dest="cmd", required=True)
# ---- in1 ----
p1 = sub.add_parser("in1", help="监听 IN1(自动适配 event / sysfs)")
p1.add_argument("-d", "--device", default="auto",
help="事件设备路径;auto=自动查找(默认)。"
"SE5 用 /dev/input/eventN,SE9 自动回退到 sysfs。")
p1.add_argument("-k", "--key", type=lambda s: int(s, 0), default=KEY_UP,
help="键码,默认 103 (KEY_UP)")
p1.add_argument("-t", "--duration", type=float, default=0,
help="监听秒数,0=一直跑")
p1.add_argument("--press-command", help="有电时执行的 shell 命令")
p1.add_argument("--release-command", help="断电时执行的 shell 命令")
p1.add_argument("--active-low", dest="active_low", action="store_true",
default=True, help="12V 接通上报为“有电”(默认)")
p1.add_argument("--no-active-low", dest="active_low", action="store_false",
help="不翻转,按驱动/文件原始语义")
p1.set_defaults(func=cmd_in1, interval=None)
# ---- in2 ----
p2 = sub.add_parser("in2", help="监听 IN2(优先 sysfs/SE9,回退 gpio-keys event/SE5)")
p2.add_argument("interval", nargs="?", type=float, default=DEFAULT_INTERVAL,
help=f"sysfs 轮询间隔秒(默认 {DEFAULT_INTERVAL})")
p2.add_argument("--check", action="store_true",
help="只自检:复算编号 + 打印当前电平")
p2.add_argument("-d", "--device", default=None,
help="显式指定 event 设备(回退 event 模式用);默认自动选择")
p2.add_argument("-k", "--key", type=lambda s: int(s, 0), default=None,
help=f"event 模式键码(默认 {DEFAULT_IN2_KEY},用 detect 查实际键码)")
p2.add_argument("--active-low", dest="active_low", action="store_true",
default=False, help="电平取反(默认不取反)")
p2.add_argument("--no-active-low", dest="active_low", action="store_false",
help="不翻转(默认)")
p2.add_argument("--press-command", help="有电时执行的 shell 命令")
p2.add_argument("--release-command", help="断电时执行的 shell 命令")
p2.add_argument("-t", "--duration", type=float, default=0,
help="监听秒数,0=一直跑")
p2.set_defaults(func=cmd_in2)
# ---- detect ----
pd = sub.add_parser("detect",
help="扫描全部 event 设备并打印按键事件(确认 IN1/IN2 键码)")
pd.add_argument("-t", "--duration", type=float, default=5.0,
help="监听秒数(默认 5)")
pd.set_defaults(func=cmd_detect)
# ---- both ----
p3 = sub.add_parser("both", help="同时监听 IN1 + IN2")
p3.add_argument("-d", "--device", default="auto",
help="IN1 事件设备路径;auto=自动(默认)")
p3.add_argument("-k", "--key", type=lambda s: int(s, 0), default=KEY_UP)
p3.add_argument("--active-low", dest="active_low", action="store_true",
default=True)
p3.add_argument("--no-active-low", dest="active_low", action="store_false")
p3.add_argument("-t", "--duration", type=float, default=0)
p3.add_argument("--interval", type=float, default=DEFAULT_INTERVAL,
help="IN2 轮询间隔秒")
p3.set_defaults(func=cmd_both)
return p
def main(argv=None):
args = build_parser().parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""全局常量与默认值。"""
import os
# ---- input 事件协议(IN1/event 后端用)----
EV_KEY = 0x01
KEY_UP = 103
KEY_DOWN = 108
VALUE_RELEASE = 0
VALUE_PRESS = 1
VALUE_REPEAT = 2
DEFAULT_EVENT_DEVICE = "/dev/input/event1"
GPIO_KEYS_NAME = "gpio-keys"
# SE5 的 IN2 与 IN1 同走 gpio-keys 事件设备(event1),仅键码不同;
# 默认取 KEY_DOWN,实测不符时用 `diwatch in2 -k <键码>` 覆盖,
# 键码可用 `diwatch detect` 现场确认。
DEFAULT_IN2_KEY = KEY_DOWN
# ---- IN 通道编号 ----
IN1_INDEX = 1
IN2_INDEX = 2
# IN2 硬件信息(用于 --check 复算)
IN2_GPIO_NUM = 483
IN2_GPIOCHIP = 480
# ---- sysfs 状态文件候选路径 ----
# 按 in_index 分组,每个通道可以有多个候选路径,按顺序尝试
STATUS_CANDIDATES = {
1: [
"/sys/bus/platform/devices/gpio-in-1/gpio_status",
"/dev/tv_gpioin_1",
# SE9 若有其它路径,加到这里
],
2: [
"/sys/bus/platform/devices/gpio-in-2/gpio_status",
"/dev/tv_gpioin_2",
],
}
STATUS_FILE_ENV = "DI_STATUS_FILE" # 测试用覆盖
LOG_DIR_ENV = "DI_LOG_DIR"
DEVICETREE_BASE = "/sys/firmware/devicetree/base"
DEBOUNCE = 2 # 连续多少次采样一致才确认跳变
DEFAULT_INTERVAL = 0.02 # 轮询间隔秒
def candidate_status_files(in_index: int):
"""返回某通道的所有候选状态文件路径(含环境变量覆盖)。"""
paths = []
env = os.environ.get(STATUS_FILE_ENV)
if env:
paths.append(env)
paths.extend(STATUS_CANDIDATES.get(in_index, []))
return paths
def find_status_file(in_index: int):
"""找到第一个可读的状态文件;找不到返回 None。"""
for p in candidate_status_files(in_index):
if os.access(p, os.R_OK):
return p
return None
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""DIChannel:统一的数字输入通道。
把不同后端(event / sysfs)归一成同一套 API:
ch = DIChannel("IN1", backend=make_in1_backend(), active_low=True)
ch.on_change = lambda old, new, ts: ...
ch.run() # 阻塞
ch.run(duration=10) # 跑 10 秒
电平语义:
对外统一用 0/1,0 = 低/断电,1 = 高/有电。
active_low=True 时,把底层原始值取反(适配光耦反相)。
"""
import datetime
import select
import time
def now_str():
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
def fmt_dur(ms):
if ms < 1000:
return f"{ms}ms"
return f"{ms / 1000:.3f}s"
class DIChannel:
"""单个数字输入通道。"""
def __init__(self, name, backend, active_low=False,
on_change=None, on_press=None, on_release=None,
on_event=None, log=None):
"""
name : 通道名,如 "IN1"
backend : Backend 实例
active_low : True 时把原始值取反(0->1, 1->0)
on_change : (old, new, ts) -> None
on_press : (ts) -> None # new=1
on_release : (ts) -> None # new=0
on_event : (ts, raw) -> None # 原始事件
log : (msg) -> None
"""
self.name = name
self.backend = backend
self.active_low = active_low
self.on_change = on_change
self.on_press = on_press
self.on_release = on_release
self.on_event = on_event
self.log = log or (lambda m: print(m, flush=True))
self._level = None
self._level_since = None
self._stop = False
self._count = 0
self._ms_low = 0
self._ms_high = 0
# ---- 上下文管理 ----
def __enter__(self):
self.open()
return self
def __exit__(self, *exc):
self.close()
def open(self):
self.backend.open()
def close(self):
self.backend.close()
# ---- 电平换算 ----
def _to_level(self, raw):
if raw is None:
return None
v = 1 if raw else 0
if self.active_low:
v = 1 - v
return v
# ---- 回调分发 ----
def _emit(self, new, ts):
old = self._level
if old == new:
return
self._level = new
now_ms = int(time.time() * 1000)
if self._level_since is not None:
dur = now_ms - self._level_since
if old == 0:
self._ms_low += dur
elif old == 1:
self._ms_high += dur
self._level_since = now_ms
self._count += 1
self.log(f"{now_str()} {self.name} {'有电' if new else '断电'}")
if self.on_change:
self.on_change(old, new, ts)
if new == 1 and self.on_press:
self.on_press(ts)
elif new == 0 and self.on_release:
self.on_release(ts)
# ---- 单次读取(用于初始化)----
def read_now(self):
"""立即读一次并返回归一化后的电平;失败返回 None。
仅对支持 read_raw 的后端(sysfs)有效;event 后端返回 None。
"""
raw = None
if hasattr(self.backend, "read_raw"):
raw = self.backend.read_raw()
if raw is None:
return None
lvl = self._to_level(int(raw))
self._level = lvl
self._level_since = int(time.time() * 1000)
return lvl
# ---- 主循环 ----
def run(self, duration=None):
"""阻塞监听。duration 秒后返回;None = 一直跑到 Ctrl-C。"""
self.open()
# 初始化当前电平
if self._level is None:
lvl = self.read_now()
if lvl is not None:
self.log(f"{self.name} 初始电平:{lvl}")
else:
# event 后端:置为 0,等第一个事件再校正
self._level = 0
self._level_since = int(time.time() * 1000)
deadline = time.time() + duration if duration else None
try:
if self.backend.can_poll:
self._run_poll(deadline)
else:
self._run_sample(deadline)
finally:
self.close()
def _run_poll(self, deadline):
poller = select.poll()
poller.register(self.backend.fileno(), select.POLLIN)
while not self._stop:
timeout = 500
if deadline:
remain = deadline - time.time()
if remain <= 0:
break
timeout = int(min(remain, 0.5) * 1000)
poller.poll(timeout)
# read() 返回本批全部事件,按序处理,快速抖动不丢中间跳变
for raw, ts in self.backend.read():
if self.on_event:
self.on_event(ts, raw)
self._emit(self._to_level(raw), ts)
def _run_sample(self, deadline):
interval = getattr(self.backend, "interval", 0.02)
while not self._stop:
if deadline and time.time() >= deadline:
break
for raw, ts in self.backend.read():
if self.on_event:
self.on_event(ts, raw)
self._emit(self._to_level(raw), ts)
time.sleep(interval)
def request_stop(self):
self._stop = True
# ---- 汇总 ----
def summary(self):
now_ms = int(time.time() * 1000)
if self._level_since is not None:
dur = now_ms - self._level_since
if self._level == 0:
self._ms_low += dur
elif self._level == 1:
self._ms_high += dur
self._level_since = now_ms
return {
"name": self.name,
"count": self._count,
"ms_low": self._ms_low,
"ms_high": self._ms_high,
"level": self._level,
}
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""从设备树复算 gpio-in-N 对应的全局 GPIO 号。"""
from pathlib import Path
from .config import DEVICETREE_BASE
def _read_be32(path: Path, offset: int = 0):
try:
data = path.read_bytes()
except OSError:
return None
if len(data) < offset + 4:
return None
return int.from_bytes(data[offset:offset + 4], "big")
def derive_gpio(n: int, base_dir: str = DEVICETREE_BASE):
"""从设备树复算 IN<n> 的全局 GPIO 号。
返回 (gpio_num, label, pin) 或 None。
"""
base = Path(base_dir)
f = base / f"gpio-in-{n}" / "control-gpios"
if not f.exists():
return None
try:
data = f.read_bytes()
except OSError:
return None
if len(data) < 8:
return None
ph = int.from_bytes(data[0:4], "big")
pin = int.from_bytes(data[4:8], "big")
# phandle -> gpio-controller 节点
ctrl = None
for c in base.glob("gpio@*/gpio-controller@*"):
cph = _read_be32(c / "phandle")
if cph == ph:
ctrl = c
break
if ctrl is None:
return None
bank = ctrl.parent.name
if bank.startswith("gpio@"):
bank = bank[len("gpio@"):]
label = f"{bank}.gpio"
# label -> gpiochip base
gpio_class = Path("/sys/class/gpio")
if not gpio_class.exists():
return None
for g in gpio_class.glob("gpiochip*"):
try:
if (g / "label").read_text().strip() == label:
chip_base = int((g / "base").read_text().strip())
return chip_base + pin, label, pin
except (OSError, ValueError):
continue
return None
+222
View File
@@ -0,0 +1,222 @@
"""
edge_app_controller — 外部 MQTT 请求处理(请求-响应协议)
订阅主题: /edge_app_controller
回复主题: /edge_app_controller_reply
请求消息 (JSON): {"command": "<命令名>", ...业务字段}
兼容协议: {"Event": "/alg_media_fetch", "BoardId": "..."}(Event 取最后一段作为命令)
回复消息 (JSON): {"code": 0, "msg": "ok", "data": {...}}
- code: 0 成功;非 0 失败
-1 缺少 command 字段
-2 未知命令
-3 命令执行异常
不回复约定:
命令处理函数返回 None 时,该命令只处理、不回复(静默命令,如心跳/日志类)。
适用于按命令/Event 控制是否回复,无需修改配置。
命令通过 _COMMANDS 注册表分发,新增命令只需在注册表登记一个 async 处理函数。
"""
import logging
from typing import Any, Callable, Coroutine
logger = logging.getLogger(__name__)
def _reply(code: int, msg: str, data: Any = None) -> dict:
"""统一回复封装"""
return {"code": code, "msg": msg, "data": data}
async def edge_app_controller(payload: dict, topic: str) -> dict | None:
"""
处理来自外部 Broker 的边缘应用控制器请求(订阅 /edge_app_controller)
Args:
payload: 请求消息(已解析为 dict),期望含 command 字段
topic: 实际命中的主题
Returns:
回复消息(统一封装 code / msg / data),由订阅客户端发布到 reply_topic;
命令函数返回 None 时返回 None(不回复)。
"""
command = (
payload.get("command")
or payload.get("cmd")
or payload.get("action")
or payload.get("Event")
)
# Event 可能是路径形式(如 /alg_media_fetch),取最后一段作为命令名
if isinstance(command, str) and command.startswith("/"):
command = command.rsplit("/", 1)[-1]
logger.info(
f"[EdgeAppController] 收到请求 topic={topic}, command={command}, payload={payload}"
)
if command is None:
return None
handler = _COMMANDS.get(str(command))
if handler is None:
logger.warning(f"[EdgeAppController] 未知命令: {command}")
return None
try:
data = await handler(payload)
# 命令函数返回 None 表示该命令只处理、不回复(如心跳/日志类静默命令)
if data is None:
logger.info(f"[EdgeAppController] 命令 {command} 处理完成(配置为不回复)")
return None
logger.info(f"[EdgeAppController] 命令 {command} 执行成功")
return data
except Exception as e:
logger.error(f"[EdgeAppController] 命令 {command} 执行失败: {e}")
return None
# ============================================================
# 内置命令(示例)
# ============================================================
async def _cmd_ping(payload: dict) -> dict | None:
"""存活探测:返回服务信息"""
return {"service": "aibox_sophon_adapter", "alive": True}
async def _cmd_ack(payload: dict) -> None:
"""
确认命令(示例):仅记录日志,不回复
返回 None 表示不回复 —— 需要"某些命令静默处理"时,让命令函数返回 None 即可。
"""
logger.info(f"[EdgeAppController] 命令 ack 已收到(不回复),payload={payload}")
return None
async def _cmd_query_status(payload: dict) -> dict | None:
"""
查询边缘应用/设备状态
复用插件已有的设备查询逻辑(router.list_devices),返回设备在线状态列表。
后续可按需扩展:查询指定应用运行状态、通道状态等。
"""
from .router.list_devices import list_devices
result = await list_devices()
return result
async def _cmd_alg_media_fetch(payload: dict) -> dict | None:
"""
算法媒体抓取:按 BoardId 返回设备最近一次心跳中的算法/通道(媒体)信息
请求: {"Event": "/alg_media_fetch", "BoardId": "..."}
返回: {"board_id": ..., "medias": [...], "tasks": [...]}
"""
from .global_var import (
db,
internal_mqtt,
internal_request_topic,
internal_response_topic,
)
board_id = payload.get("BoardId")
if not board_id:
return None
print(f"[alg_media_fetch]Processing for board: {board_id}")
print(f"[alg_media_fetch]Expected payload: {payload}")
if db is None:
return None
if internal_mqtt is None:
return None
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) # type: ignore
result = await session.execute(stmt)
record: BoardHeartbeat | None = result.scalar_one_or_none()
if record is None:
print(f"[alg_media_fetch]Board {board_id} not found in database.")
return None
# 内部请求-响应主题由 [workers.config.internal_mqtt] 配置,与盒子侧约定一致
# (盒子只设根主题 box,子路径固定 aibox_sophon/...,回复主题固定为 ..._reply)。
# 勿用 chongming/{插件}/{操作}:网关 Bridge 订阅 chongming/+/+,会拦截
# 3 级主题并按 Worker RPC 路由,而 edge_app_controller 不是 @rpc_method
# (Worker 无对应 Queryable),会导致 worker_manager 报 "无回复"。
resp = await internal_mqtt.request(
request_topic=internal_request_topic,
response_topic=internal_response_topic,
payload=payload,
timeout=5
)
print(f"[alg_media_fetch]Response from internal_mqtt: {resp}")
if resp is None:
return None
return resp
async def _cmd_alg_task_fetch(payload: dict) -> dict | None:
"""
算法任务抓取:按 BoardId 返回设备最近一次心跳中的算法任务信息
请求: {"Event": "/alg_task_fetch", "BoardId": "..."}
返回: {"board_id": ..., "tasks": [...]}
"""
from .global_var import (
db,
internal_mqtt,
internal_request_topic,
internal_response_topic,
)
board_id = payload.get("BoardId")
if not board_id:
return None
print(f"[alg_task_fetch]Processing for board: {board_id}")
print(f"[alg_task_fetch]Expected payload: {payload}")
if db is None:
return None
if internal_mqtt is None:
return None
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) # type: ignore
result = await session.execute(stmt)
record: BoardHeartbeat | None = result.scalar_one_or_none()
if record is None:
print(f"[alg_task_fetch]Board {board_id} not found in database.")
return None
resp = await internal_mqtt.request(
request_topic=internal_request_topic,
response_topic=internal_response_topic,
payload=payload,
timeout=5
)
return resp
# 命令注册表:command -> async handler(payload) -> data(返回 None 表示不回复)
_COMMANDS: dict[str, Callable[[dict], Coroutine[Any, Any, Any]]] = {
"ping": _cmd_ping,
"ack": _cmd_ack,
"query_status": _cmd_query_status,
"alg_media_fetch": _cmd_alg_media_fetch,
"alg_task_fetch": _cmd_alg_task_fetch,
}
+70 -1
View File
@@ -3,20 +3,76 @@ AI Box Sophon Adapter — 全局变量
模块级共享资源管理。
"""
from typing import Any
from typing import Any, TYPE_CHECKING
from chongming_database import Database
from .models.orm import *
from chongming_stream_manager import StreamManager
if TYPE_CHECKING:
from chongming_mqtt.mqtt_forward import MQTTForwardClient
from chongming_mqtt.mqtt_subscribe import MQTTSubscribeClient
from chongming_mqtt.internal_mqtt import InternalMQTTClient
from .task_switch import In2TaskSwitch
# 数据库实例
db: Database | None = None
# 视频流管理器
stream_manager: StreamManager | None = None
link_path: str | None = None
# 外部 MQTT 转发客户端
mqtt_forward: "MQTTForwardClient | None" = None
# 外部 MQTT 订阅客户端
mqtt_subscribe: "MQTTSubscribeClient | None" = None
# 内部 MQTT 请求-响应客户端(handler 内对任意应用进程发起请求-响应)
internal_mqtt: "InternalMQTTClient | None" = None
# 内部请求-响应主题(edge_app_controller 的 alg_media_fetch 命令使用)
# 与盒子侧约定一致(盒子只设根主题 box,子路径固定 aibox_sophon/...);
# 勿用 chongming/{插件}/{操作} 保留命名空间(网关 Bridge 订阅 chongming/+/+ 会拦截 3 级主题并按 Worker RPC 路由)
internal_request_topic: str = "box/aibox_sophon/edge_app_controller"
internal_response_topic: str = "box/aibox_sophon/edge_app_controller_reply"
# 上传文件根目录(用于清理告警关联视频)
upload_root: str = "./data/uploads"
# 告警保留配置
alarm_max_records: int = 0 # 0 表示不限制
# 豆包 AI 分析开关
doubao_analysis_enabled: bool = True
# ip 音柱 TTS 播放开关
ip_sound_column_enabled: bool = True
# ip 音柱 TTS 播放音量(0-100)
ip_sound_column_volume: int = 50
# ip 音柱 TTS 播放内容(key: Summary,value: tts 文本)
ip_sound_column_tts: dict = {}
# 告警转发配置
alarm_forward_enabled: bool = False
alarm_forward_url: str = "http://dg.datagoo.cn:60014/open-api/event-alarm/receive"
alarm_forward_video_url: str = "http://dg.datagoo.cn:60014/open-api/event-alarm/receiveVideoUpload"
alarm_forward_timeout: float = 10.0
# 心跳超时配置(秒)
heartbeat_timeout: float = 15.0
# 心跳上报间隔(秒)
heartbeat_interval: float = 5.0
# ffmpeg 是否可用(on_init 时探测;不可用时跳过 RTSP 拉流,不阻断心跳)
ffmpeg_available: bool = True
# IN2 任务启停开关控制器(DI 有电启动全部任务,断电停止全部任务)
task_switch: "In2TaskSwitch | None" = None
def set_global_var(name: str, value: Any) -> None:
"""
@@ -33,5 +89,18 @@ __all__ = [
"db",
"heartbeat_timeout",
"heartbeat_interval",
"mqtt_forward",
"mqtt_subscribe",
"internal_mqtt",
"internal_request_topic",
"internal_response_topic",
"upload_root",
"alarm_max_records",
"doubao_analysis_enabled",
"alarm_forward_enabled",
"alarm_forward_url",
"alarm_forward_video_url",
"alarm_forward_timeout",
"ffmpeg_available",
"set_global_var",
]
-2
View File
@@ -4,7 +4,6 @@
"""
from .pydantic import AiboxSophonAdapterResponse
from .pydantic import AlarmReportPostResponse
from .pydantic import GetAlarmGetRequest
from .pydantic import GetAlarmGetResponse
from .pydantic import GetDeviceGetRequest
@@ -16,7 +15,6 @@ from .pydantic import ListDevicesGetResponse
__all__ = [
"AiboxSophonAdapterResponse",
"AlarmReportPostResponse",
"GetAlarmGetRequest",
"GetAlarmGetResponse",
"GetDeviceGetRequest",
+35 -5
View File
@@ -5,7 +5,7 @@
未声明的额外字段通过 model_config extra=allow 保留。
"""
from typing import Any, Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
# ============================================================
@@ -14,6 +14,8 @@ from pydantic import BaseModel, Field
class GpsInfo(BaseModel):
"""GPS 位置信息"""
model_config = {"extra": "allow"}
available: bool = Field(default=False, description="是否有 GPS 功能")
kSpeed: float = Field(default=0.0, description="对地速度 (km/h)")
nSpeed: float = Field(default=0.0, description="对地速率 (节)")
@@ -21,9 +23,19 @@ class GpsInfo(BaseModel):
longitude: float = Field(default=0.0, description="经度")
angleCourse: float = Field(default=0.0, description="GPS 角度")
@field_validator('kSpeed', 'nSpeed', 'latitude', 'longitude', 'angleCourse', mode='before')
@classmethod
def _empty_str_to_zero(cls, v: Any) -> Any:
"""设备可能上报空字符串,转为 0.0"""
if v == '' or v is None:
return 0.0
return v
class MediaInfo(BaseModel):
"""视频通道信息"""
model_config = {"extra": "allow"}
MediaName: str = Field(default="", description="通道标识")
MediaDesc: str = Field(default="", description="通道描述")
MediaUrl: str = Field(default="", description="通道视频流地址")
@@ -32,11 +44,21 @@ class MediaInfo(BaseModel):
SubId: str = Field(default="", description="关联 GB28181 通道号")
GBTransport: bool = Field(default=False, description="是否接入 GB28181")
RtspTransport: bool = Field(default=False, description="是否开启 RTSP 转发")
Params: Optional[dict] = Field(default=None, description="预留其他参数")
Params: Optional[Any] = Field(default=None, description="预留其他参数,可能是 dict、list 或 null")
@field_validator('MediaWidth', 'MediaHeight', mode='before')
@classmethod
def _empty_str_to_zero(cls, v: Any) -> Any:
"""设备可能上报空字符串,转为 0"""
if v == '' or v is None:
return 0
return v
class ResultProperty(BaseModel):
"""告警 Result 中的属性项"""
model_config = {"extra": "allow"}
property: str = Field(default="", description="属性标识")
desc: str = Field(default="", description="属性描述")
value: Any = Field(default=None, description="属性值(String/Int/Float)")
@@ -90,7 +112,7 @@ class AlarmReportRequest(BaseModel):
LocalLabeledPath: str = Field(default="", description="有标注告警图在盒子中的相对路径")
# 通道信息
Media: MediaInfo = Field(default_factory=MediaInfo, description="配置的媒体通道")
Media: Optional[MediaInfo] = Field(default=None, description="配置的媒体通道")
# 违规描述
Summary: str = Field(default="", description="违规类型(NoHelmet / Intrude / PeopleCross 等)")
@@ -109,16 +131,24 @@ class AlarmReportRequest(BaseModel):
VideoFile: str = Field(default="", description="关联的视频标识(视频上传接口返回)")
# GPS
GPS: GpsInfo = Field(default_factory=GpsInfo, description="当前位置信息")
GPS: Optional[GpsInfo] = Field(default=None, description="当前位置信息")
# 违规结果
Result: AlarmResult = Field(default_factory=AlarmResult, description="违规检测结果")
Result: Optional[AlarmResult] = Field(default=None, description="违规检测结果")
# 预留字段
Type: int = Field(default=0, description="预留字段")
DataId: int = Field(default=0, description="数据 ID")
Id: int = Field(default=0, description="记录 ID")
@field_validator('DataId', 'Id', 'TimeStamp', 'Type', mode='before')
@classmethod
def _empty_str_to_zero(cls, v: Any) -> Any:
"""设备可能上报空字符串,转为 0"""
if v == '' or v is None:
return 0
return v
# ============================================================
# 告警上报响应体
@@ -130,4 +130,3 @@ class DeviceSummary(BaseModel):
last_ping_time: Optional[str] = Field(default=None, description="最后心跳时间")
grant_code: int = Field(..., description="授权码")
grant_desc: str = Field(..., description="授权描述")
+1 -5
View File
@@ -8,10 +8,6 @@ from __future__ import annotations
from typing import Any, Optional
from pydantic import BaseModel, Field
class AlarmReportPostResponse(BaseModel):
"""告警上报接口 — 响应体"""
Result: dict[str, Any] = Field(...)
class GetAlarmGetRequest(BaseModel):
"""查询单条告警详情 — 请求体"""
alarm_id: str = Field(...)
@@ -67,4 +63,4 @@ class ListDevicesGetResponse(BaseModel):
# 所有响应模型的联合类型
AiboxSophonAdapterResponse = GetOfflineDevicesGetResponse | AlarmReportPostResponse | GetAlarmGetResponse | GetDeviceGetResponse | ListAlarmsGetResponse | ListDevicesGetResponse
AiboxSophonAdapterResponse = ListDevicesGetResponse | ListAlarmsGetResponse | GetAlarmGetResponse | GetOfflineDevicesGetResponse | GetDeviceGetResponse
+222
View File
@@ -0,0 +1,222 @@
"""
AI Box Sophon Adapter — MQTT 客户端能力组件
从插件 __init__.py 抽取的三个外部 MQTT 客户端启动块,统一模式:
- enabled=False → 仅警告"未启用"并返回 None
- enabled=True → 懒加载客户端类,启动失败降级为 None(仅警告,不阻断插件)
内部 MQTT 主题约定(勿改):
与盒子侧约定一致(盒子只设根主题 box,子路径固定 aibox_sophon/...);
勿用 chongming/{插件}/{操作} 保留命名空间——网关 MQTT Bridge 订阅了
chongming/+/+,会拦截 3 级主题并按 Worker RPC 路由,而 edge_app_controller
不是 @rpc_method(Worker 无对应 Queryable),导致 worker_manager 报
"Worker ... 无回复,尝试重试"。
"""
import logging
import os
from typing import Any
logger = logging.getLogger(__name__)
# 插件包名(作为 importlib 探测的基准,兼容 plugins.xxx 与 xxx 两种导入路径)
_PKG = __package__ or "plugins.aibox_sophon_adapter"
def _import_first(candidates: list) -> Any:
"""按序尝试导入模块,全部失败返回 None。"""
import importlib
for name in candidates:
try:
return importlib.import_module(name)
except ImportError:
continue
return None
def build_subscribe_routes(topics_cfg: list) -> list:
"""
根据配置的订阅主题列表构建 MQTTSubscribeClient 路由。
topics_cfg: [{"topic": "...", "handler": "board_ping", "reply_topic": "..."}, ...]
- topic: 订阅主题(支持 + / # 通配符)
- handler: 处理函数名,按序从插件 router 模块、插件包自身查找
- reply_topic: 可选,回复主题;处理后返回值将发布到该主题(支持 {1}{2} 占位符)
适配约定(按首个位置参数类型):
- Pydantic 模型: 用 payload 构造模型实例后调用(如 board_ping -> BoardPingRequest(**payload))
- dict: 直接传整个 payload 和 topic(如 edge_app_controller(payload, topic))
- 其他: 将 payload 作为关键字参数展开调用
返回: [{"topic": ..., "handler": async callable(payload, topic), "reply_topic": ...}]
"""
from typing import get_origin
from pydantic import BaseModel
# 注意: 不能用 "from .. import router" 取子模块,因为插件包属性 router
# 已被 routes.router(FastAPI APIRouter)遮蔽,故用 importlib 按名导入。
pkg_base = _PKG
pkg_leaf = _PKG.rsplit(".", 1)[-1]
router_mod = _import_first([f"{pkg_base}.router", f"{pkg_leaf}.router"])
pkg_mod = _import_first([pkg_base, pkg_leaf])
routes = []
for t in topics_cfg or []:
topic = t.get("topic", "")
handler_name = t.get("handler", "")
reply_topic = t.get("reply_topic")
if not topic or not handler_name:
logger.warning("[MQTTSubscribe] 跳过无效订阅项: %s", t)
continue
fn = None
if router_mod is not None:
fn = getattr(router_mod, handler_name, None)
if fn is None and pkg_mod is not None:
fn = getattr(pkg_mod, handler_name, None)
if fn is None:
logger.warning(
"[MQTTSubscribe] 未知 handler '%s',跳过 %s", handler_name, topic
)
continue
# 探测首个位置参数的类型注解
hints = getattr(fn, "__annotations__", {})
first_type: Any = None
for pname, ptype in hints.items():
if pname in ("worker_id", "return"):
continue
first_type = ptype
break
if (
isinstance(first_type, type)
and issubclass(first_type, BaseModel)
):
model_cls = first_type
async def _cb_model(data, topic, _fn=fn, _model=model_cls):
return await _fn(_model(**data))
routes.append({
"topic": topic,
"handler": _cb_model,
"reply_topic": reply_topic,
})
elif first_type is dict or get_origin(first_type) is dict:
# 处理函数期望整个 dict 作为第一个参数(如 edge_app_controller(payload, topic))
async def _cb_payload(data, topic, _fn=fn):
return await _fn(data, topic)
routes.append({
"topic": topic,
"handler": _cb_payload,
"reply_topic": reply_topic,
})
else:
# 其他签名:将 payload 按关键字参数展开调用
async def _cb_kwargs(data, topic, _fn=fn):
if isinstance(data, dict):
return await _fn(**data)
return await _fn()
routes.append({
"topic": topic,
"handler": _cb_kwargs,
"reply_topic": reply_topic,
})
return routes
async def start_mqtt_forward(cfg: dict) -> Any:
"""启动外部 MQTT 转发客户端,返回客户端或 None。"""
enabled = cfg.get("enabled", False)
if not enabled:
logger.warning("[MQTTForward] mqtt_forward 未启用,配置内容: %s", cfg)
return None
from chongming_mqtt.mqtt_forward import MQTTForwardClient
# uri 含凭据,优先配置;留空时从环境变量 AIBOX_MQTT_URI 兜底(避免凭据入库)
uri = cfg.get("uri") or os.environ.get("AIBOX_MQTT_URI", "")
topic = cfg.get("topic", "/board_ping")
logger.warning(
"[MQTTForward] 配置已加载: enabled=%s, uri=%s, topic=%s", enabled, uri, topic
)
if not uri:
return None
client = MQTTForwardClient(uri=uri, topic=topic)
try:
await client.start()
logger.warning("[MQTTForward] 外部转发已启用: %s → %s", uri, topic)
return client
except Exception as e:
logger.warning("[MQTTForward] 启动失败,将跳过转发: %s", e)
return None
async def start_mqtt_subscribe(cfg: dict) -> Any:
"""启动外部 MQTT 订阅客户端(本插件直接订阅外部 Broker,不走网关 Bridge)。"""
enabled = cfg.get("enabled", False)
if not enabled:
logger.warning("[MQTTSubscribe] mqtt_subscribe 未启用,配置内容: %s", cfg)
return None
from chongming_mqtt.mqtt_subscribe import MQTTSubscribeClient
# uri 含凭据,优先配置;留空时从环境变量 AIBOX_MQTT_URI 兜底(避免凭据入库)
uri = cfg.get("uri") or os.environ.get("AIBOX_MQTT_URI", "")
topics = cfg.get("topics", []) or []
logger.warning(
"[MQTTSubscribe] 配置已加载: enabled=%s, uri=%s, topics=%s",
enabled, uri, topics,
)
if not (uri and topics):
logger.warning("[MQTTSubscribe] 未配置 uri 或 topics,跳过外部订阅")
return None
routes = build_subscribe_routes(topics)
client = MQTTSubscribeClient(uri=uri, routes=routes)
try:
await client.start()
logger.warning("[MQTTSubscribe] 外部订阅已启用: %s", uri)
return client
except Exception as e:
logger.warning("[MQTTSubscribe] 启动失败,将跳过订阅: %s", e)
return None
async def start_internal_mqtt(cfg: dict) -> tuple[Any, str, str]:
"""
启动内部 MQTT 请求-响应客户端
(handler 内对任意应用进程发起请求-响应,主题显式传入,默认连内部 127.0.0.1:1883)。
返回 (client | None, request_topic, response_topic):
主题无论是否启用都按配置解析并返回(由调用方写入 global_var)。
"""
req_topic = cfg.get(
"request_topic", "box/aibox_sophon/edge_app_controller"
)
resp_topic = cfg.get(
"response_topic", "box/aibox_sophon/edge_app_controller_reply"
)
enabled = cfg.get("enabled", False)
if not enabled:
logger.warning("[InternalMQTT] internal_mqtt 未启用,配置内容: %s", cfg)
return None, req_topic, resp_topic
from chongming_mqtt.internal_mqtt import InternalMQTTClient
uri = cfg.get("uri", "mqtt://127.0.0.1:1883")
logger.warning(
"[InternalMQTT] 配置已加载: enabled=%s, uri=%s, request_topic=%s, response_topic=%s",
enabled, uri, req_topic, resp_topic,
)
client = InternalMQTTClient(uri=uri)
try:
await client.start()
logger.warning("[InternalMQTT] 内部请求-响应客户端已启用: %s", uri)
return client, req_topic, resp_topic
except Exception as e:
logger.warning("[InternalMQTT] 启动失败,将跳过: %s", e)
return None, req_topic, resp_topic
+43 -8
View File
@@ -1,10 +1,11 @@
"""
AI Box Sophon Adapter — MQTT 心跳上报处理器
监听 MQTT 主题 /board_ping(通过 MQTT Bridge 路由到 chongming/aibox_sophon_adapter/board_ping),
监听 MQTT 主题 box/aibox_sophon/board_ping(通过 MQTT Bridge 路由到
aibox_sophon_adapter.board_ping,勿用 chongming/ 保留命名空间),
解析设备心跳数据并更新数据库记录。
"""
import json
import orjson
import logging
from datetime import datetime
@@ -27,10 +28,14 @@ async def board_ping(request: BoardPingRequest, worker_id: str = "") -> dict:
设备通过 MQTT 主题 /board_ping 定期(默认 5 秒)上报系统状态。
收到心跳后更新数据库中的设备记录,标记为在线。
"""
from ..global_var import db, heartbeat_timeout
from ..global_var import (
db, heartbeat_timeout, stream_manager, link_path, mqtt_forward, ffmpeg_available,
)
board_id = request.BoardId
ping_json = request.model_dump_json()
# ---- 心跳落库(核心步骤,失败才视为心跳处理失败) ----
try:
if db is None:
raise RuntimeError("Database not initialized")
@@ -43,7 +48,6 @@ async def board_ping(request: BoardPingRequest, worker_id: str = "") -> dict:
record: BoardHeartbeat | None = result.scalar_one_or_none()
now = datetime.now()
ping_json = request.model_dump_json()
if record is None:
# 新设备,创建记录
@@ -81,6 +85,41 @@ async def board_ping(request: BoardPingRequest, worker_id: str = "") -> dict:
record.updated_at = now
await session.commit()
except Exception as e:
logger.error(f"[Heartbeat] 处理心跳失败 (board_id={board_id}): {e}")
return {"error": str(e)}
# ---- 以下为可选能力:失败只告警,不阻断心跳 ----
ping_data = orjson.loads(ping_json.encode("utf-8"))
# RTSP 拉流(依赖 ffmpeg;缺失时跳过并在启动日志提示)
medias = ping_data.get("Medias", {})
pending = [
media for media in medias
if "正常" in media.get("MediaStatus", {}).get("label", "")
]
if pending and stream_manager is None:
logger.warning("[Heartbeat] StreamManager 未初始化,跳过 RTSP 拉流")
elif pending and not ffmpeg_available:
logger.warning("[Heartbeat] ffmpeg 不可用,跳过 RTSP 拉流(安装 ffmpeg 后恢复)")
else:
for media in pending:
stream_id = media["MediaName"]
source_url = media["MediaUrl"].replace("/Link", link_path)
try:
await stream_manager.start_stream(f"/{stream_id}", source_url) # type: ignore
except Exception as e:
logger.warning(f"[Heartbeat] 通道 {stream_id} 拉流失败: {e}")
# 转发心跳数据到外部 MQTT Broker
if mqtt_forward is not None:
success = await mqtt_forward.publish(ping_data)
if not success:
logger.warning("[MQTTForward] 心跳转发失败")
else:
logger.debug(f"[MQTTForward] 心跳已转发 ({len(ping_data)} keys)")
else:
logger.debug("[MQTTForward] mqtt_forward 客户端未启用,跳过转发")
logger.debug(
f"[Heartbeat] {board_id}: temp={request.BoardTemp}, "
@@ -89,8 +128,4 @@ async def board_ping(request: BoardPingRequest, worker_id: str = "") -> dict:
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}
+3 -3
View File
@@ -1,7 +1,7 @@
"""
查询单条告警详情 — GET /aibox/get_alarm?alarm_id=xxx
"""
import json
import orjson
import logging
from chongming_worker import rpc_method
@@ -34,8 +34,8 @@ async def get_alarm(alarm_id: str, worker_id: str = "") -> dict:
alarm_data = {}
try:
alarm_data = json.loads(record.alarm_data) if record.alarm_data else {}
except json.JSONDecodeError:
alarm_data = orjson.loads(record.alarm_data.encode("utf-8")) if record.alarm_data else {}
except orjson.JSONDecodeError:
pass
return {
+3 -3
View File
@@ -1,7 +1,7 @@
"""
查询单个设备详情 — GET /aibox/get_device?board_id=xxx
"""
import json
import orjson
import logging
from datetime import datetime
@@ -39,8 +39,8 @@ async def get_device(board_id: str, worker_id: str = "") -> dict:
ping_data = {}
try:
ping_data = json.loads(record.ping_data) if record.ping_data else {}
except json.JSONDecodeError:
ping_data = orjson.loads(record.ping_data.encode("utf-8")) if record.ping_data else {}
except orjson.JSONDecodeError:
pass
return {
@@ -1,7 +1,6 @@
"""
查询离线/异常设备 — GET /aibox/get_offline_devices
"""
import json
import logging
from datetime import datetime
+4 -4
View File
@@ -1,7 +1,7 @@
"""
查询告警记录列表 — GET /aibox/list_alarms
"""
import json
import orjson
import logging
from chongming_worker import rpc_method
@@ -49,7 +49,7 @@ async def list_alarms(
total = count_result.scalar() or 0
# 分页数据
stmt = select(AlarmRecord).order_by(desc(AlarmRecord.created_at))
stmt = select(AlarmRecord).order_by(desc(AlarmRecord.created_at)) # type: ignore
if conditions:
from sqlalchemy import and_
stmt = stmt.where(and_(*conditions))
@@ -60,8 +60,8 @@ async def list_alarms(
for record in records:
alarm_data = {}
try:
alarm_data = json.loads(record.alarm_data) if record.alarm_data else {}
except json.JSONDecodeError:
alarm_data = orjson.loads(record.alarm_data.encode("utf-8")) if record.alarm_data else {}
except orjson.JSONDecodeError:
pass
alarms.append({
+3 -3
View File
@@ -1,7 +1,7 @@
"""
查询所有设备状态 — GET /aibox/list_devices
"""
import json
import orjson
import logging
from datetime import datetime
@@ -39,8 +39,8 @@ async def list_devices(worker_id: str = "") -> dict:
ping_data = {}
try:
ping_data = json.loads(record.ping_data) if record.ping_data else {}
except json.JSONDecodeError:
ping_data = orjson.loads(record.ping_data.encode("utf-8")) if record.ping_data else {}
except orjson.JSONDecodeError:
pass
devices.append({
+434 -71
View File
@@ -2,18 +2,27 @@
AI Box Sophon Adapter — 自定义 FastAPI 路由
处理 TOML 无法描述的特殊端点:
- POST /aibox/video_upload 视频上传(FORM-DATA)
- POST /aibox/alarm_report 告警上报(JSON,手动解析)
- POST /aibox_sophon_adapter/video_upload 视频上传(FORM-DATA)
- GET /aibox_sophon_adapter/video/{id} 视频下载/播放
- POST /aibox_sophon_adapter/alarm_report 告警上报(JSON,手动解析)
路由前缀: /aibox(网关自动加 /api/v1)
路由前缀: /aibox_sophon_adapter(网关自动加 /api/v1)
"""
import os
import json
import orjson
import uuid
import hashlib
import logging
from fastapi import APIRouter, UploadFile, File, Form, Request
from aiofile import AIOFile
import aiofiles
import aiofiles.os
import httpx
from fastapi import APIRouter, UploadFile, File, Form, Request, Body, HTTPException
from fastapi.responses import FileResponse
from pydantic import ValidationError
from chongming_request import Client
from .models.video_models import VideoUploadResponse, VideoUploadResult
from .models.alarm_models import (
@@ -26,7 +35,312 @@ logger = logging.getLogger(__name__)
# 自定义路由器,前缀与 config.toml 中的 router_prefix 保持一致
# 最终路径由网关 register_routes() 加上全局 prefix (/api/v1) 组合而成
router = APIRouter(prefix="/aibox", tags=["aibox_sophon_adapter"])
router = APIRouter(prefix="/aibox_sophon_adapter", tags=["aibox_sophon_adapter"])
class _FileStream:
"""
文件流 — 用于流式上传视频,避免一次性将整个文件读入内存。
注意: httpx 0.28 的 multipart 上传(FileField)要求文件对象提供同步的
read()/seek() 方法,httpx 内部以 64KB 分块同步读取;AsyncByteStream 只能
作为整个请求体 content= 使用,无法与 multipart 字段共存。因此这里提供
同步文件接口,配合同步上下文管理器 with 使用(本地磁盘小分块读取,
内存占用依然有界)。
"""
def __init__(self, file_path: str):
self._file_path = file_path
self._file = None
def __enter__(self) -> "_FileStream":
self._file = open(self._file_path, "rb")
return self
def __exit__(self, exc_type, exc, tb) -> None:
if self._file is not None:
self._file.close()
self._file = None
def read(self, size: int = -1) -> bytes:
assert self._file is not None, "必须在 with 上下文内使用"
return self._file.read(size)
def seek(self, offset: int, whence: int = 0) -> int:
assert self._file is not None, "必须在 with 上下文内使用"
return self._file.seek(offset, whence)
# ============================================================
# 辅助函数 - 播放 tts 语音
# ============================================================
async def ip_sound_column_tts_play(zenoh_session, payload: dict) -> dict:
"""
播放 ip 音柱 tts 语音
可通过配置 ip_sound_column.enabled 关闭。失败时返回 {"command": ..., "result": -1}。
Args:
zenoh_session: Zenoh 会话(用于调用 ip_sound_column Worker)
payload: 请求体字典,包含 command / volume / play_count / queue / sync_response /
play_interval / tts_pitch / tts_speed / tts_voice_name / tts_text
"""
from .global_var import ip_sound_column_enabled
if not ip_sound_column_enabled:
logger.info("[TTS] ip_sound_column 播放已关闭 (ip_sound_column.enabled=false)")
return {"command": payload.get("command", ""), "result": -1}
async with Client(session=zenoh_session, logger=logger) as client:
try:
result = await client.call(
worker_type="ip_sound_column",
subject="ip_sound_column.tts",
method="POST",
payload=payload,
timeout=10.0,
retries=1,
)
return result
except Exception as e:
logger.error(f"[TTS] ip_sound_column 调用失败: {type(e).__name__}: {e}")
return {"command": payload.get("command", ""), "result": -1}
# ============================================================
# 辅助函数 — 告警处理各步骤拆分为独立函数
# ============================================================
async def _analyze_with_doubao(zenoh_session, full_data: dict) -> bool:
"""
豆包 AI 分析告警图片,判断是否存在违规。
可通过配置 doubao_analysis.enabled 关闭。判定为 False 时返回 False(忽略告警)。
Args:
zenoh_session: Zenoh 会话(用于调用 doubao Worker)
full_data: 完整告警报文
Returns:
True: 继续处理告警;False: 判定为 False,应忽略告警。
"""
from .global_var import doubao_analysis_enabled
if not doubao_analysis_enabled:
logger.info("[AlarmReport] 豆包分析已关闭 (doubao_analysis.enabled=false)")
return True
async with Client(session=zenoh_session, logger=logger) as client:
try:
result = await client.call(
worker_type="doubao",
subject="doubao.analyze",
payload={
"image_base64": full_data.get("ImageData", ""),
"prompt": f"判断是否存在{full_data.get("Summary", "")},直接返回True或False,不要输出其他内容",
},
timeout=30.0,
retries=1,
)
analysis = result.get("result", "")
error = result.get("error", "")
if error:
logger.warning(f"Doubao 分析失败: {error}")
if analysis == "False":
return False
except Exception as e:
logger.error(f"Doubao 分析调用失败: {type(e).__name__}: {e}")
return True
async def _save_alarm_record(alarm: dict) -> None:
"""
将告警写入本地数据库(按 AlarmId 去重,超出上限时裁剪旧记录)。
Args:
alarm: 完整告警报文字典。
"""
from .global_var import db, alarm_max_records, upload_root
if db is None:
return
alarm_id = alarm.get("AlarmId") or alarm.get("UniqueId", "")
media = alarm.get("Media", {})
media_name = media.get("MediaName", "") if isinstance(media, dict) else ""
alarm_data_str = orjson.dumps(alarm).decode()
try:
async with db.get_session() as session:
from .models.orm import AlarmRecord
from sqlalchemy import select
stmt = select(AlarmRecord).where(AlarmRecord.alarm_id == alarm_id) # type: ignore
existing = (await session.execute(stmt)).scalar_one_or_none()
if existing is not None:
logger.warning(f"[AlarmReport] 重复告警,已忽略: AlarmId={alarm_id}")
return
record = AlarmRecord(
board_id=alarm.get("BoardId", ""),
board_ip=alarm.get("BoardIp", ""),
alarm_id=alarm_id,
summary=alarm.get("Summary", ""),
task_session=alarm.get("TaskSession", ""),
media_name=media_name,
video_file=alarm.get("VideoFile", ""),
alarm_time=alarm.get("Time", ""),
alarm_timestamp=alarm.get("TimeStamp", 0),
alarm_data=alarm_data_str,
)
session.add(record)
await session.commit()
logger.info(
f"[AlarmReport] 新告警: AlarmId={alarm_id}, "
f"BoardId={record.board_id}, Summary={record.summary}, "
f"Media={media_name}, VideoFile={record.video_file}"
)
# 裁剪告警记录,保留最新 N 条
if alarm_max_records > 0:
from .alarm_trim import trim_alarm_records
await trim_alarm_records(session, upload_root, alarm_max_records)
except Exception as e:
logger.error(f"[AlarmReport] 数据库写入失败: {e}")
async def _upload_video_to_external(
http_client: httpx.AsyncClient,
video_url: str,
board_ip: str,
board_id: str,
task_session: str,
video_file: str,
) -> str:
"""
上传本地告警视频到外部平台,返回外部 VideoId。
本地视频存储于 data/uploads/aibox_sophon_adapter/<video_file>/ 目录。
采用流式上传,避免将整个文件读入内存。失败时返回空字符串。
Args:
http_client: httpx 异步客户端
video_url: 外部视频上传接口地址
board_ip / board_id / task_session: 上传表单字段
video_file: 本地 VideoId(目录名)
Returns:
外部返回的 VideoId;失败返回空字符串。
"""
from .global_var import upload_root
video_dir_path = os.path.join(upload_root, "aibox_sophon_adapter", video_file)
if not await aiofiles.os.path.isdir(video_dir_path):
logger.warning(f"[AlarmReport] 视频目录不存在: {video_dir_path}")
return ""
video_files = await aiofiles.os.listdir(video_dir_path)
if not video_files:
logger.warning(f"[AlarmReport] 视频目录为空: {video_dir_path}")
return ""
video_path = os.path.join(video_dir_path, video_files[0])
try:
# httpx 0.28 的 multipart 以同步 read() 流式读取文件(64KB 分块),
# 因此这里用同步 with 打开文件流
with _FileStream(video_path) as video_stream:
resp = await http_client.post(
video_url,
data={
"BoardIp": board_ip,
"BoardId": board_id,
"TaskSession": task_session,
},
files={"Video": (video_files[0], video_stream, "video/mp4")}, # type: ignore[arg-type]
)
if resp.is_success:
video_id = resp.json().get("VideoId", "")
logger.info(f"[AlarmReport] 视频上传成功: VideoId={video_id}, URL={video_url}")
return video_id
else:
logger.warning(
f"[AlarmReport] 视频上传失败: Status={resp.status_code}, Body={resp.text[:500]}"
)
except Exception as e:
logger.error(f"[AlarmReport] 视频上传异常: Error={type(e).__name__}: {e}")
return ""
async def _forward_alarm_to_external(alarm: dict) -> None:
"""
转发告警到外部平台:先上传视频获取外部 VideoId,替换 VideoFile 后再上报告警。
可通过配置 alarm_forward.enabled 关闭。所有失败仅记录日志,不影响主流程。
Args:
alarm: 完整告警报文字典。
"""
from .global_var import (
alarm_forward_enabled,
alarm_forward_url,
alarm_forward_video_url,
alarm_forward_timeout,
)
if not (alarm_forward_enabled and alarm_forward_url):
return
alarm_id = alarm.get("AlarmId") or alarm.get("UniqueId", "")
board_id = alarm.get("BoardId", "")
board_ip = alarm.get("BoardIp", "")
task_session = alarm.get("TaskSession", "")
video_file = alarm.get("VideoFile", "")
try:
async with httpx.AsyncClient(timeout=alarm_forward_timeout) as http_client:
# 1. 先上传视频到外部接口
forward_video_id = await _upload_video_to_external(
http_client,
alarm_forward_video_url,
board_ip,
board_id,
task_session,
video_file,
)
# 2. 替换 VideoFile 为外部返回的 VideoId,然后上报告警
forward_data = {**alarm}
if forward_video_id:
forward_data["VideoFile"] = forward_video_id
resp = await http_client.post(
alarm_forward_url,
json=forward_data,
headers={"Content-Type": "application/json"},
)
if resp.is_success:
logger.info(
f"[AlarmReport] 转发成功: AlarmId={alarm_id}, "
f"URL={alarm_forward_url}, Status={resp.status_code}"
)
else:
logger.warning(
f"[AlarmReport] 转发失败: AlarmId={alarm_id}, "
f"URL={alarm_forward_url}, Status={resp.status_code}, "
f"Body={resp.text[:500]}"
)
except httpx.TimeoutException:
logger.error(
f"[AlarmReport] 转发超时: AlarmId={alarm_id}, "
f"URL={alarm_forward_url}, Timeout={alarm_forward_timeout}s"
)
except Exception as e:
logger.error(
f"[AlarmReport] 转发异常: AlarmId={alarm_id}, "
f"URL={alarm_forward_url}, Error={type(e).__name__}: {e}"
)
@router.post(
@@ -54,6 +368,8 @@ async def video_upload(
"""
# 生成唯一 VideoId
video_id = uuid.uuid4().hex
# 获取设备声明的文件大小(若 Starlette 不支持则记 -1)
declared_size = getattr(Video, 'size', -1) or -1
try:
# 复用网关存储路径约定: data/uploads/<worker_type>/<uuid>/<filename>
@@ -61,20 +377,26 @@ async def video_upload(
worker_type = "aibox_sophon_adapter"
object_key = f"{worker_type}/{video_id}/{Video.filename}"
dest = os.path.join(upload_root, object_key)
os.makedirs(os.path.dirname(dest), exist_ok=True)
await aiofiles.os.makedirs(os.path.dirname(dest), exist_ok=True)
# 流式写入文件(与网关 /api/v1/storage/upload 一致)
async with AIOFile(dest, "wb") as afp:
# 流式写入文件,同时计算 MD5 和总字节数
total_bytes = 0
md5_hash = hashlib.md5()
async with aiofiles.open(dest, "wb") as afp:
while chunk := await Video.read(1024 * 1024):
await afp.write(chunk)
total_bytes += len(chunk)
md5_hash.update(chunk)
file_size = os.path.getsize(dest)
file_size = await aiofiles.os.path.getsize(dest)
file_md5 = md5_hash.hexdigest()
logger.info(
f"[VideoUpload] 视频已保存: VideoId={video_id}, "
f"BoardId={BoardId}, BoardIp={BoardIp}, "
f"TaskSession={TaskSession}, File={Video.filename}, "
f"Size={file_size} bytes, Path={object_key}"
f"declared={declared_size}, received={total_bytes}, disk={file_size}, "
f"md5={file_md5}, Path={object_key}"
)
return VideoUploadResponse(
@@ -85,7 +407,7 @@ async def video_upload(
except Exception as e:
logger.error(
f"[VideoUpload] 上传失败: BoardId={BoardId}, "
f"BoardIp={BoardIp}, Error={e}"
f"BoardIp={BoardIp}, Error={type(e).__name__}: {e}"
)
return VideoUploadResponse(
Result=VideoUploadResult(Code=-1, Desc=str(e)),
@@ -93,84 +415,125 @@ async def video_upload(
)
@router.get(
"/video/{video_id}",
summary="视频下载/播放",
description="根据 VideoId 下载或流式播放告警关联视频",
)
async def video_download(
request: Request,
video_id: str,
) -> FileResponse:
"""
视频播放端点
根据 video_upload 返回的 VideoId 查找并返回视频文件。
支持浏览器内嵌播放(Content-Disposition: inline)。
"""
upload_root = request.app.state.upload_root
worker_type = "aibox_sophon_adapter"
# 在 uploads 目录下查找匹配的视频文件
video_dir = os.path.join(upload_root, worker_type, video_id)
if not await aiofiles.os.path.isdir(video_dir):
raise HTTPException(status_code=404, detail=f"视频不存在: {video_id}")
# 取目录下第一个文件作为视频(通常只有一个)
files = await aiofiles.os.listdir(video_dir)
if not files:
raise HTTPException(status_code=404, detail=f"视频目录为空: {video_id}")
video_path = os.path.join(video_dir, files[0])
if not await aiofiles.os.path.isfile(video_path):
raise HTTPException(status_code=404, detail=f"视频文件不存在: {files[0]}")
return FileResponse(
video_path,
media_type="video/mp4",
headers={"Content-Disposition": "inline"},
)
@router.post(
"/alarm_report",
summary="告警上报接口",
description="接收盒子告警报文(JSON),所有字段已在 OpenAPI 中声明,未知字段自动保留",
response_model=AlarmReportResponse,
)
async def alarm_report(body: AlarmReportRequest) -> AlarmReportResponse:
async def alarm_report(
request: Request,
payload: dict = Body(..., description="告警报文 JSON 对象"),
) -> AlarmReportResponse:
"""
告警上报接口
盒子检测到违规事件后,先上传视频再上报 JSON 告警报文。
请求体所有字段已在 Pydantic 模型中声明(含默认值),
model_config extra=allow 确保盒子固件升级新增字段不会丢失。
使用手动 Pydantic 验证以便在验证失败时记录完整请求体,便于排查。
业务处理:
1. Pydantic 验证基础字段类型
2. model_dump() 获取完整 dict(含额外字段)
3. 提取关键索引字段存入 alarm_record 表
4. 完整报文 JSON 字符串存入 alarm_data
处理流程:
1. 手动 Pydantic 验证基础字段类型(失败时记录原始 JSON)
2. 豆包 AI 分析(判定为 False 则忽略告警)
3. 写入本地 alarm_record 表(含完整报文 JSON)
4. 转发外部平台(先上传视频,替换 VideoFile 后再上报)
返回格式:{"Result": {"Code": 0, "Desc": ""}}
仅当 HTTP 200 + Code 非 0 时,盒子认为上报失败。
"""
# 获取完整报文(含 extra 字段)
full_data = body.model_dump()
board_id = full_data.get("BoardId", "")
board_ip = full_data.get("BoardIp", "")
alarm_id = full_data.get("AlarmId") or full_data.get("UniqueId", "")
summary = full_data.get("Summary", "")
task_session = full_data.get("TaskSession", "")
video_file = full_data.get("VideoFile", "")
media = full_data.get("Media", {})
media_name = media.get("MediaName", "") if isinstance(media, dict) else ""
alarm_time = full_data.get("Time", "")
alarm_timestamp = full_data.get("TimeStamp", 0)
alarm_data_str = json.dumps(full_data, ensure_ascii=False)
# 手动 Pydantic 验证,失败时记录完整原始请求体
try:
from .global_var import db
if db is not None:
async with db.get_session() as session:
from .models.orm import AlarmRecord
from sqlalchemy import select
body = AlarmReportRequest.model_validate(payload)
except ValidationError as e:
logger.error(
f"[AlarmReport] Pydantic 验证失败:\n"
f" 错误: {e.errors()}\n"
f" 请求体: {orjson.dumps(payload).decode()[:3000]}"
)
raise HTTPException(status_code=422, detail=e.errors())
stmt = select(AlarmRecord).where(AlarmRecord.alarm_id == alarm_id) # type: ignore
result = await session.execute(stmt)
existing = result.scalar_one_or_none()
# 获取完整报文(含 extra 字段,排除 None 值以生成整洁 JSON)
full_data = body.model_dump(exclude_none=True)
if existing is None:
record = AlarmRecord(
board_id=board_id,
board_ip=board_ip,
alarm_id=alarm_id,
summary=summary,
task_session=task_session,
media_name=media_name,
video_file=video_file,
alarm_time=alarm_time,
alarm_timestamp=alarm_timestamp,
alarm_data=alarm_data_str,
# 1. 豆包 AI 分析(可通过配置开关关闭),判定为 False 时忽略告警
if not await _analyze_with_doubao(request.app.state.zenoh_session, full_data):
return AlarmReportResponse(
Result=AlarmReportResult(Code=0, Desc="Doubao 分析判定为 False,忽略告警")
)
session.add(record)
await session.commit()
logger.info(
f"[AlarmReport] 新告警: AlarmId={alarm_id}, "
f"BoardId={board_id}, Summary={summary}, "
f"Media={media_name}, VideoFile={video_file}"
# 2. ip 音柱 TTS 播放(可通过配置开关关闭),失败时仅记录日志,不影响主流程
from .global_var import ip_sound_column_volume, ip_sound_column_tts
properties = full_data.get("Result", {}).get("Properties", [])
area = ""
if len(properties) > 0:
properties_dict = {prop.get("property", ""): prop.get("value", "") for prop in properties}
area = f"{properties_dict.get('IntrudeRegionId', '')},"
tts_text = ip_sound_column_tts.get(
full_data.get("Summary", ""),
f"告警: {area}{full_data.get('TaskDesc', '')}"
)
else:
logger.warning(
f"[AlarmReport] 重复告警,已忽略: AlarmId={alarm_id}"
)
except Exception as e:
logger.error(f"[AlarmReport] 数据库写入失败: {e}")
tts_payload = {
"command": "play_local_tts",
"volume": ip_sound_column_volume,
"play_count": 1,
"queue": True,
"sync_response": False,
"play_interval": 2,
"tts_pitch": 50,
"tts_speed": 50,
"tts_voice_name": "xiaoyan",
"tts_text": tts_text,
}
tts_result = await ip_sound_column_tts_play(request.app.state.zenoh_session, tts_payload)
if tts_result.get("result") != 0:
logger.warning(f"[AlarmReport] ip_sound_column TTS 播放失败: {tts_result}")
# 3. 写入本地数据库(按 AlarmId 去重 + 裁剪旧记录)
await _save_alarm_record(full_data)
# 4. 转发外部平台(先上传视频,再上报)
await _forward_alarm_to_external(full_data)
return AlarmReportResponse(
Result=AlarmReportResult(Code=0, Desc="")
+196
View File
@@ -0,0 +1,196 @@
"""
AI Box Sophon Adapter — IN2 数字输入控制任务启停
监听盒子 IN2 DI 输入(复用 diwatch 的 DIChannel + SysfsBackend,sysfs 轮询),
电平变化时通过盒子本地 HTTP 接口控制所有算法任务:
- IN2 有电(电平 1)→ 启动全部任务(ControlCommand = start_command,默认 1)
- IN2 断电(电平 0)→ 停止全部任务(ControlCommand = stop_command,默认 0)
盒子接口(POST,JSON):
- /api/alg_task_fetch 查询全部任务:{} → {"Content": [{"AlgTaskSession": ...}], "Result": {"Code": 0}}
- /api/alg_task_control 控制单个任务:{"AlgTaskSession": ..., "ControlCommand": 0/1}
→ {"Result": {"Code": 0, "Desc": "已停止"}}
说明:
- 只响应电平跳变,不按启动时的初始电平动作(避免插件重启时误触发全量启停)。
- DIChannel 阻塞轮询跑在独立线程,回调经 run_coroutine_threadsafe 调度回事件循环,
HTTP 控制在事件循环内串行执行(apply_lock 防止快速抖动导致并发穿插)。
"""
import asyncio
import logging
import threading
import httpx
from .diwatch.backends import EventBackend, make_in2_backend
from .diwatch.core import DIChannel
logger = logging.getLogger(__name__)
class In2TaskSwitch:
"""IN2 数字输入 → 全部算法任务启停开关。
后端自适应(make_in2_backend):SE9 走 sysfs gpio_status 轮询;
SE5 无 gpio-in-2 时回退 gpio-keys event 设备(默认键码 KEY_DOWN=108,
可用 device/key 参数覆盖,键码用 diwatch detect 查询)。
"""
def __init__(
self,
api_base: str = "http://127.0.0.1",
start_command: int = 1,
stop_command: int = 0,
poll_interval: float = 0.05,
debounce: int = 3,
active_low: bool = False,
device: str | None = None,
key: int | None = None,
timeout: float = 10.0,
):
self.api_base = api_base.rstrip("/")
self.start_command = start_command
self.stop_command = stop_command
self.poll_interval = poll_interval
self.debounce = debounce
self.active_low = active_low
self.device = device
self.key = key
self.timeout = timeout
self._channel: DIChannel | None = None
self._thread: threading.Thread | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._apply_lock = asyncio.Lock()
# ---- 生命周期 ----
def start(self, loop: asyncio.AbstractEventLoop) -> bool:
"""启动监听线程;找不到 IN2 可用后端时返回 False(非盒子环境属正常)。"""
backend = make_in2_backend(
device=self.device, key=self.key,
interval=self.poll_interval, debounce=self.debounce,
)
if backend is None:
logger.warning(
"[TaskSwitch] 找不到 IN2 可用后端(sysfs 状态文件与 gpio-keys "
"event 设备均不存在),任务启停开关未启动(非盒子环境属正常现象)"
)
return False
self._channel = DIChannel(
"IN2", backend,
active_low=self.active_low,
on_change=self._on_level_change,
log=lambda m: logger.debug(f"[TaskSwitch] {m}"),
)
self._loop = loop
self._thread = threading.Thread(
target=self._run_channel, name="in2-task-switch", daemon=True,
)
self._thread.start()
backend_desc = (
f"event:{getattr(backend, 'device', '?')}, 键码={backend.key}"
if isinstance(backend, EventBackend)
else f"sysfs:{backend.path}"
)
logger.info(
f"[TaskSwitch] IN2 任务启停开关已启动: 后端={backend_desc}, "
f"api_base={self.api_base}, "
f"启动命令={self.start_command}, 停止命令={self.stop_command}"
)
if isinstance(backend, EventBackend):
logger.info(
"[TaskSwitch] event 后端无初始电平,以首个事件为准;"
"若操作 IN2 无反应,用 diwatch detect 查实际键码并配置 task_switch.key"
)
return True
def stop(self) -> None:
if self._channel is not None:
self._channel.request_stop()
if self._thread is not None:
self._thread.join(timeout=2.0)
self._thread = None
logger.info("[TaskSwitch] IN2 任务启停开关已停止")
def _run_channel(self) -> None:
try:
self._channel.run() # type: ignore
except Exception as e:
logger.error(f"[TaskSwitch] IN2 监听线程异常退出: {type(e).__name__}: {e}")
# ---- DI 回调(轮询线程内执行)----
def _on_level_change(self, old: int, new: int, ts: float) -> None:
logger.info(f"[TaskSwitch] IN2 电平变化: {old} -> {new}")
if self._loop is not None:
asyncio.run_coroutine_threadsafe(self.apply(new), self._loop)
# ---- 任务控制(事件循环内执行)----
async def apply(self, level: int) -> None:
"""按目标电平控制全部任务:1=启动,0=停止。"""
async with self._apply_lock:
command = self.start_command if level == 1 else self.stop_command
action = "启动" if level == 1 else "停止"
sessions = await self._fetch_task_sessions()
if not sessions:
logger.warning("[TaskSwitch] 未获取到任何任务,跳过控制")
return
logger.info(
f"[TaskSwitch] IN2 {'有电' if level else '断电'},"
f"{action}全部 {len(sessions)} 个任务: {sessions}"
)
ok = 0
failed = 0
async with httpx.AsyncClient(timeout=self.timeout) as client:
for session in sessions:
try:
resp = await client.post(
f"{self.api_base}/api/alg_task_control",
json={"AlgTaskSession": session, "ControlCommand": command},
)
result = resp.json().get("Result", {})
if resp.is_success and result.get("Code") == 0:
ok += 1
logger.info(
f"[TaskSwitch] 任务 {session} {action}成功: "
f"{result.get('Desc', '')}"
)
else:
failed += 1
logger.warning(
f"[TaskSwitch] 任务 {session} {action}失败: "
f"Status={resp.status_code}, Body={resp.text[:200]}"
)
except Exception as e:
failed += 1
logger.error(
f"[TaskSwitch] 任务 {session} {action}异常: "
f"{type(e).__name__}: {e}"
)
logger.info(f"[TaskSwitch] {action}完成: 成功 {ok}, 失败 {failed}")
async def _fetch_task_sessions(self) -> list[str]:
"""查询全部任务的 AlgTaskSession 列表;失败返回空列表。"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
# resp = await client.post(f"{self.api_base}/api/alg_task_fetch", json={})
resp = await client.get(f"{self.api_base}/api/alg_task_fetch")
if not resp.is_success:
logger.warning(
f"[TaskSwitch] 查询任务失败: Status={resp.status_code}, "
f"Body={resp.text[:200]}"
)
return []
data = resp.json()
result = data.get("Result", {})
if result.get("Code") != 0:
logger.warning(f"[TaskSwitch] 查询任务返回错误: {result}")
return []
content = data.get("Content") or []
return [t.get("AlgTaskSession") for t in content if t.get("AlgTaskSession")]
except Exception as e:
logger.error(f"[TaskSwitch] 查询任务异常: {type(e).__name__}: {e}")
return []
+41 -29
View File
@@ -1,26 +1,36 @@
# Auth Worker 插件
# Auth Worker 插件 — 用户管理
用户认证与授权插件,提供完整的用户管理系统。
auth 域拆分后的**用户管理插件**(可替换的业务部分)。
登录 / token 签发 / admin 种子已**内置在网关**(管理平面底线,不依赖本插件是否
加载,本插件缩容到 0 或全部副本故障时登录与 admin API 仍可用)。
共享契约归 `chongming-auth` 包(`utils/auth/`):Users 模型(唯一权威定义)、
建库、登录校验、JWT 签发。本插件与网关内置登录**共用同一张 users 表**。
---
## 功能
- **用户注册**:用户名 + 邮箱 + 密码注册,自动校验唯一性
- **用户登录**:JWT Token 签发,有效期 3 小时
- **获取用户信息**:根据 JWT Token 获取完整用户档案
- **修改密码**:旧密码验证 + 新密码更新
- **更新资料**:更新全名、邮箱、头像、手机号等信息
- **自动初始化**:首次启动自动创建 `admin` 管理员账户
(登录 `POST /auth/login` 由网关内置提供,路径与响应 shape 不变)
---
## 数据库
使用 SQLite(通过 `chongming-database` 异步封装),数据库文件位于 `data/storage/chongming/auth.db`。
使用 SQLite(通过 `chongming-database` 异步封装),数据库文件位于
`data/auth.db`(`[workers.config].data_dir` 下);配置 `database.url` 时走
PostgreSQL。库位置与网关内置登录读同一份配置(本插件 `config.toml` 的
`[workers.config]`),指向同一张 users 表。
### Users 表结构
权威定义见 `chongming_auth.models.Users`(本仓库 `models/auth.py` 仅为 re-export):
| 字段 | 类型 | 说明 |
| ----------------- | ----------------- | --------------------------------- |
| `id` | `str` (PK) | UUID 格式主键 |
@@ -45,6 +55,27 @@
所有端点路由前缀为 `/auth`。
### `POST /auth/login` — 登录(网关内置)
无需认证。由网关核心 `chongming/app/routes/auth.py` 提供(非本插件),
路径与响应 shape 与拆分前一致。JWT claims 契约见 `chongming-auth` 包 README。
```json
// 请求
{
"username": "admin",
"password": "admin"
}
// 响应
{
"result": "success",
"token": "eyJhbGciOiJIUzI1NiIs...",
"message": "登录成功",
"timestamp": 1720000000.123
}
```
### `POST /auth/register` — 用户注册
无需认证。
@@ -68,28 +99,6 @@
}
```
### `POST /auth/login` — 登录
无需认证。
```json
// 请求
{
"username": "admin",
"password": "admin"
}
// 响应
{
"result": "success",
"token": "eyJhbGciOiJIUzI1NiIs...",
"message": "登录成功",
"timestamp": 1720000000.123
}
```
JWT Token 有效期 3 小时,载荷包含:`sub`(用户ID)、`username`、`email`、`full_name`、`is_superuser` 等。
### `GET /auth/me?user_id=<id>` — 获取用户信息
需要 JWT 鉴权(`Authorization: Bearer <token>`)。
@@ -159,6 +168,10 @@ JWT Token 有效期 3 小时,载荷包含:`sub`(用户ID)、`username`
## 默认管理员账户
admin 种子逻辑随登录一并内置到网关(网关 lifespan 的 `setup_auth_store`
→ `chongming_auth.seed_admin`,幂等,多进程并发启动安全),不再依赖本插件。
数据库 `auth.db` 本插件仍由 `Plugin` 基类统一初始化并在 cleanup 时释放。
首次启动时自动创建:
| 字段 | 值 |
@@ -186,8 +199,7 @@ JWT Token 有效期 3 小时,载荷包含:`sub`(用户ID)、`username`
| 工具包 | 用途 |
| -------------------- | ------------------------------------ |
| `chongming-auth` | Users 模型 / 建库 / 登录校验 / 签发(共享契约) |
| `chongming-database` | 异步 SQLite 数据库操作 |
| `chongming-jwt` | Rust JWT 编码/验证 |
| `chongming-password` | Rust Argon2id 密码哈希 |
| `chongming-config` | 全局配置加载 |
| `chongming-worker` | `@rpc_method` 装饰器 + Worker 运行时 |
+39 -46
View File
@@ -1,52 +1,45 @@
from pathlib import Path
from datetime import datetime
"""
Auth Worker 插件 — 用户管理(注册 / 资料 / 改密)
from chongming_worker import auto_bind_models
from chongming_worker.runtime import create_workers as _create_workers
from chongming_password import hash_password
auth 域拆分后本插件只保留用户管理功能(可替换的业务部分);
登录 / token 签发 / admin 种子已内置在网关(chongming_auth 包 +
chongming.app.routes.auth),不依赖本插件是否加载。
from .router import *
基于 Plugin 基类统一生命周期:数据库由基类初始化(auth.db,与网关内置
登录共用同一张 users 表),on_cleanup 无需重复 dispose。
"""
import logging
from chongming_worker.plugin import Plugin
from .router import * # noqa: F401,F403 触发 @rpc_method 注册
# 模块加载时即注册 SQLModel 元数据 —— 基类 _bootstrap 建表(create_all)只创建
# 已导入的表,若拖到 on_init 才导入会导致全新库缺少 users 表(no such table)。
# Users 权威定义在 chongming_auth 包,此处 re-export 注册元数据。
from .models.auth import Users # noqa: F401
logger = logging.getLogger(__name__)
auto_bind_models("auth")
class AuthPlugin(Plugin):
"""Auth 插件 — 统一生命周期。"""
type = "auth"
db_name = "auth.db" # 自动在 data_dir 下建 SQLite 库(与网关内置登录共用)
auto_bind = True # 首次初始化时自动 auto_bind_models("auth")
async def on_init(self):
"""首次 create_workers 时调用一次。"""
# 写入全局变量供 router 使用(数据库由基类在 cleanup 时统一 dispose)
self.set_var("db", self.db)
async def on_cleanup(self):
"""cleanup 时调用(数据库由基类处理)。"""
logger.info("[auth] 清理完成")
async def create_workers(session, config, count=1, worker_manager=None):
from chongming_database import Database
from .global_var import set_global_var
from .models.auth import Users
from sqlalchemy import select
db_path = Path(config.get("data_dir", "./data")) / "auth.db"
db = Database(db_path)
await db.initialize()
# 初始化管理员
async with db.get_session() as sess:
select_stmt = select(Users) \
.where(Users.username == "admin") # type: ignore
result = await sess.execute(select_stmt)
user: Users | None = result.scalar_one_or_none()
if user is None:
user = Users(
username="admin",
hashed_password=hash_password("admin"),
email="admin@example.com",
created_at=datetime.now(),
updated_at=datetime.now(),
is_superuser=True
)
sess.add(user)
await sess.commit()
set_global_var("db", db)
return _create_workers(session, config, count, worker_manager)
async def cleanup(worker_item: str):
from .global_var import db
if db is not None:
await db.dispose()
print(f"{worker_item} 清理完毕")
# 暴露给 app lifespan 的模块级入口(bound method,签名与旧写法完全一致)
plugin = AuthPlugin()
create_workers = plugin.create_workers
cleanup = plugin.cleanup
+2 -5
View File
@@ -1,7 +1,6 @@
from typing import Any
from chongming_config import get_config
from chongming_jwt import JWT
from chongming_database import Database
from .models import *
@@ -19,11 +18,9 @@ config = get_config()
db: Database | None = None
secret = config["security"]["secret_key"]
algorithm = config["security"]["algorithm"]
jwt = JWT(secret, algorithm)
# JWT 签发已随登录内置到网关(chongming.app.routes.auth / chongming_auth 包),
# 插件不再持有 jwt 实例
__all__ = [
"db",
"jwt"
]
+1 -4
View File
@@ -1,13 +1,12 @@
"""
模型导出 — auth
自动生成,请勿手动修改
(LoginPostRequest/LoginPostResponse 已随内置登录迁移至 chongming_auth 包)
"""
from .pydantic import AuthResponse
from .pydantic import ChangePasswordPostRequest
from .pydantic import ChangePasswordPostResponse
from .pydantic import LoginPostRequest
from .pydantic import LoginPostResponse
from .pydantic import MeGetRequest
from .pydantic import MeGetResponse
from .pydantic import RegisterPostRequest
@@ -21,8 +20,6 @@ __all__ = [
"AuthResponse",
"ChangePasswordPostRequest",
"ChangePasswordPostResponse",
"LoginPostRequest",
"LoginPostResponse",
"MeGetRequest",
"MeGetResponse",
"RegisterPostRequest",
+8 -100
View File
@@ -1,102 +1,10 @@
import os
import time
from typing import Optional
from datetime import datetime
from uuid import uuid4
"""Users 模型 re-export —— 权威定义已迁移至共享包 chongming_auth。
from sqlmodel import Field, SQLModel, Column
from sqlalchemy import func, DateTime
auth 域拆分后:Users 模型 / 建库 / 登录校验 / JWT 签发归 chongming_auth 包
(网关内置登录与 auth 插件共用,指向同一张 users 表)。本模块保留 re-export
以兼容插件内既有相对导入(from ..models.auth import Users);
re-export 保证全进程单一模型定义,SQLModel 元数据不会重复注册。
"""
from chongming_auth import Users # noqa: F401
def primary_key_create():
return f"{uuid4()}_{os.getpid()}_{int(time.time())}"
class Users(SQLModel, table=True):
__tablename__ = "users" # type: ignore
# ---------- 主键 ----------
id: str = Field(
default_factory=primary_key_create,
primary_key=True,
index=True,
description="用户唯一ID(UUID格式)"
)
# ---------- 登录凭证(唯一索引) ----------
username: str = Field(
max_length=50,
unique=True,
index=True,
nullable=False,
description="登录用户名"
)
email: str = Field(
max_length=255,
unique=True,
index=True,
nullable=False,
description="电子邮箱"
)
hashed_password: str = Field(
max_length=255,
nullable=False,
description="加密后的密码(不存明文)"
)
# ---------- 个人信息(可选) ----------
full_name: Optional[str] = Field(
default=None,
max_length=100,
description="用户全名"
)
avatar_url: Optional[str] = Field(
default=None,
max_length=500,
description="头像图片链接"
)
phone: Optional[str] = Field(
default=None,
max_length=20,
description="手机号码"
)
# ---------- 安全与状态标识 ----------
is_active: bool = Field(
default=True,
description="账户是否启用(用于软禁用)"
)
is_verified: bool = Field(
default=False,
description="邮箱/手机是否已验证"
)
is_superuser: bool = Field(
default=False,
description="是否为超级管理员"
)
# ---------- 审计时间戳(数据库自动维护) ----------
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
comment="创建时间"
)
)
updated_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
comment="最后更新时间"
)
)
# ---------- 最后登录记录(可选) ----------
last_login_at: Optional[datetime] = Field(
default=None,
sa_column=Column(DateTime(timezone=True), nullable=True),
description="最后一次登录时间"
)
__all__ = ["Users"]
+2 -13
View File
@@ -20,18 +20,6 @@ class ChangePasswordPostResponse(BaseModel):
message: str = Field(...)
timestamp: Optional[float] = Field(default=None)
class LoginPostRequest(BaseModel):
"""登录 — 请求体"""
username: str = Field(...)
password: str = Field(...)
class LoginPostResponse(BaseModel):
"""登录 — 响应体"""
result: str = Field(...)
token: str = Field(...)
message: str = Field(...)
timestamp: Optional[float] = Field(default=None)
class MeGetRequest(BaseModel):
"""获取当前用户信息 — 请求体"""
user_id: str = Field(...)
@@ -83,4 +71,5 @@ class UpdateProfilePostResponse(BaseModel):
# 所有响应模型的联合类型
AuthResponse = LoginPostResponse | ChangePasswordPostResponse | MeGetResponse | UpdateProfilePostResponse | RegisterPostResponse
# (登录响应 LoginPostResponse 已随内置登录迁移至 chongming_auth 包)
AuthResponse = ChangePasswordPostResponse | MeGetResponse | UpdateProfilePostResponse | RegisterPostResponse
+1 -2
View File
@@ -1,8 +1,7 @@
from .login import login
from .register import register
from .me import me
from .update_profile import update_profile
from .change_password import change_password
__all__ = ["login", "register", "me", "update_profile", "change_password"]
__all__ = ["register", "me", "update_profile", "change_password"]
-85
View File
@@ -1,85 +0,0 @@
import time
from datetime import datetime
import json
from sqlalchemy import select
from chongming_worker import rpc_method
from chongming_password import verify_password
from ..models.pydantic import (
LoginPostRequest,
LoginPostResponse
)
from ..models.auth import Users
@rpc_method(
subject="auth.login",
method="POST",
summary="登录",
response_model=LoginPostResponse,
)
async def login(login_post_request: LoginPostRequest) -> LoginPostResponse:
from ..global_var import jwt, db
if db is None:
raise RuntimeError("Database not initialized")
async with db.get_session() as session:
statement = select(Users).filter_by(
username=login_post_request.username,
)
result = await session.execute(statement)
user: Users | None = result.scalar_one_or_none()
if user is None:
return LoginPostResponse(
result="fail",
message="用户名错误",
token="",
timestamp=time.time()
)
if not verify_password(login_post_request.password, user.hashed_password):
return LoginPostResponse(
result="fail",
message="密码错误",
token="",
timestamp=time.time()
)
if not user.is_active:
return LoginPostResponse(
result="fail",
message="用户已经注销或被禁用",
token="",
timestamp=time.time()
)
user.last_login_at = datetime.now()
payload = {
"sub": user.id,
"username": user.username,
"email": user.email,
"full_name": user.full_name,
"avatar_url": user.avatar_url,
"phone": user.phone,
"is_verified": user.is_verified,
"is_superuser": user.is_superuser,
"created_at": user.created_at.isoformat(),
"updated_at": user.updated_at.isoformat(),
"last_login_at": user.last_login_at.isoformat()
}
token = jwt.encode(
json.dumps(payload),
expires_in_seconds=10800 # 3小时有效期
)
return LoginPostResponse(
result="success",
message="登录成功",
token=token,
timestamp=time.time()
)
+61
View File
@@ -0,0 +1,61 @@
# Auth OAuth 插件 — 第三方授权登录示例(身份源插件)
基于 `chongming-auth` 共享契约实现**身份源插件**的参考实现:准入判断归插件,
平台凭证签发归 `chongming_auth.issue_token`(claims 契约与网关内置登录完全一致)。
第三方身份登录后拿到的 token 与内置登录**完全等价**——网关 JWT 验签、
`user_info` 随信封透传、admin API 判超管全部无缝生效。
## 提供的身份源
| 身份源 | 模式 | 端点 |
| --- | --- | --- |
| GitHub | OAuth2 授权码(SPA JSON 流程) | `GET /auth/oauth/github_authorize`、`POST /auth/oauth/github_callback` |
| 手机号 | 一次性验证码(演示模式) | `POST /auth/oauth/phone_send_code`、`POST /auth/oauth/phone_login` |
## 身份源插件的标准形态(写新身份源时照此扩展)
1. **准入**:校验第三方凭据(OAuth code / OTP / LDAP 密码 / ...);
2. **映射**:在插件自有绑定表 `identity_bindings(provider, provider_uid, user_id)`
中 find-or-create——老用户换身份源登录仍是同一账号体系;
3. **建号**:无绑定时新建 `users` 账号(密码为随机不可用值)+ 绑定记录,
与网关内置登录**共用同一张 users 表**(`chongming_auth.open_database`);
4. **签发**:`chongming_auth.issue_token(user, secret, algorithm)`。
扩展新身份源(LDAP / 企业微信 / 飞书 / OIDC ...)只需:加一个 provider 对接模块
+ 一组 TOML 路由 + 一个 `@rpc_method` 端点,调 `store.find_or_create_user` 即可。
## 配置
`config/workers/auth_oauth/config.toml` 的 `[workers.config]`:
```toml
return_otp_in_response = true # 演示模式:验证码随响应返回。生产必须 false 并接短信通道
[workers.config.github]
client_id / client_secret / redirect_uri / scope
```
## 安全说明(务必阅读)
- **本插件须以 inline 模式运行**(默认):`issue_token` 需要 `[security]` 签名密钥,
external 进程持有该密钥即能铸造超管 token。external 化前需平台提供内部签发 API
或按 issuer 分离密钥。
- `return_otp_in_response` 仅限本地演示,生产必须关闭并接真实短信通道。
- OAuth state 与 OTP 验证码均存共享缓存(`chongming_cache`),一次性消费、300s 过期。
## 踩坑记录(写插件必读)
**插件必须提供 `models/pydantic.py`**(gen_models 产物位置,手写等价物亦可)。
网关契约校验按 `plugins.<type>.models.pydantic` → `src.plugins....` → `workers....`
顺序探测;缺失时 `src.plugins.` 兜底路径会以第二个模块名重新执行整个插件包
(父包导入链),插件内自定义 SQLModel 表会在全局元数据中重复定义,
网关启动即崩(`Table 'xxx' is already defined for this MetaData instance`)。
## 依赖
| 工具包 | 用途 |
| --- | --- |
| `chongming-auth` | Users 模型 / 建库 / 登录校验 / issue_token(共享契约) |
| `chongming-cache` | OAuth state 与 OTP 验证码存储 |
| `chongming-worker` | `@rpc_method` 装饰器 + Worker 运行时 |
| `httpx` | GitHub OAuth API 调用 |
+65
View File
@@ -0,0 +1,65 @@
"""auth_oauth 身份源插件 — 第三方授权登录示例(基于 chongming-auth 共享契约)。
演示 auth 域拆分后的身份源插件标准形态:
- 准入判断归插件(GitHub OAuth 授权码模式 / 手机验证码模式)
- 平台凭证签发归 chongming_auth.issue_token(claims 契约与内置登录完全一致)
- 账号落地到共享 users 表,第三方身份与账号的映射存插件自有绑定表
(identity_bindings,建在同一 auth 库,不改动 chongming-auth 拥有的 users schema)
演示模式说明:
- 手机验证码默认直接随响应返回 dev_code(无短信通道的本地演示用),
生产必须接真实短信通道并关闭 return_otp_in_response
- GitHub OAuth 需在 [workers.config.github] 配置 client_id/secret/redirect_uri
"""
import logging
from chongming_worker.plugin import Plugin
from .router import * # noqa: F401,F403 触发 @rpc_method 注册
# 模块加载时即注册 SQLModel 元数据(identity_bindings 表与 users 同库,
# 由 chongming_auth.open_database 的 create_all 一并创建)
from .models import IdentityBinding # noqa: F401
logger = logging.getLogger(__name__)
class AuthOAuthPlugin(Plugin):
"""AuthOAuth 插件 — 身份源示例,统一生命周期。"""
type = "auth_oauth"
# 不用基类建库:与网关内置登录共用同一 auth 库(chongming_auth.open_database
# 按 [workers.config] 的 data_dir / database 解析,指向 users + identity_bindings)
db_name = None
auto_bind = False # 请求/响应模型由端点函数的类型注解直接提供,无需 gen_models
async def on_init(self):
"""首次 create_workers 时调用一次:打开共享 auth 库 + 缓存。"""
from chongming_auth import open_database
from chongming_cache import Cache
from chongming_config import get_config
db = await open_database(self.config)
cache = Cache.from_config(get_config()["cache"])
# 写入全局变量供 router 使用(cleanup 时统一释放)
self.set_var("db", db)
self.set_var("cache", cache)
self.set_var("plugin_config", dict(self.config or {}))
logger.info("[auth_oauth] 初始化完成(共享 auth 库 + 缓存就绪)")
async def on_cleanup(self):
"""cleanup 时调用:释放自有资源(基类不再托管本插件的数据库)。"""
db = self.get_var("db")
cache = self.get_var("cache")
if db is not None:
await db.dispose()
if cache is not None:
cache.close()
logger.info("[auth_oauth] 清理完成")
# 暴露给 app lifespan 的模块级入口(bound method,签名与旧写法完全一致)
plugin = AuthOAuthPlugin()
create_workers = plugin.create_workers
cleanup = plugin.cleanup
+27
View File
@@ -0,0 +1,27 @@
from typing import Any
from chongming_config import get_config
from chongming_database import Database
from chongming_cache import Cache
def set_global_var(name: str, value: Any) -> None:
"""
设置当前模块的全局变量。
Args:
name: 变量名(字符串)
value: 任意值
"""
globals()[name] = value
config = get_config()
db: Database | None = None
cache: Cache | None = None # chongming_cache.Cache(OAuth state / OTP 验证码)
plugin_config: dict = {} # [workers.config] 运行时配置(github 凭据等)
__all__ = [
"db",
"cache",
"plugin_config",
]
+21
View File
@@ -0,0 +1,21 @@
"""模型导出 — auth_oauth(含插件自有绑定表 + 请求/响应模型)"""
from .pydantic import (
GithubAuthorizeGetResponse,
GithubCallbackPostRequest,
PhoneLoginPostRequest,
PhoneSendCodePostRequest,
PhoneSendCodePostResponse,
)
from chongming_auth import LoginPostResponse
from .identity import IdentityBinding
__all__ = [
"GithubAuthorizeGetResponse",
"GithubCallbackPostRequest",
"LoginPostResponse",
"PhoneLoginPostRequest",
"PhoneSendCodePostRequest",
"PhoneSendCodePostResponse",
"IdentityBinding",
]
+34
View File
@@ -0,0 +1,34 @@
"""身份源绑定表 — 第三方身份(github open_id / 手机号 / ...)与 users 账号的映射。
插件自有表,建在共享 auth 库(与 users 同库不同表):
chongming-auth 拥有的 users schema 保持最小不改动,身份源扩展物归插件。
"""
from datetime import datetime
from uuid import uuid4
from sqlalchemy import UniqueConstraint, func, Column, DateTime
from sqlmodel import Field, SQLModel
def _pk() -> str:
return f"{uuid4()}_{int(datetime.now().timestamp())}"
class IdentityBinding(SQLModel, table=True):
__tablename__ = "identity_bindings" # type: ignore
__table_args__ = (
UniqueConstraint("provider", "provider_uid", name="uq_provider_uid"),
)
id: str = Field(default_factory=_pk, primary_key=True, description="绑定记录ID")
user_id: str = Field(index=True, max_length=255, description="关联的 users.id")
provider: str = Field(index=True, max_length=32, description="身份源: github / phone / ...")
provider_uid: str = Field(max_length=255, description="身份源侧唯一标识(github user id / 手机号)")
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
comment="创建时间",
)
)
+43
View File
@@ -0,0 +1,43 @@
"""auth_oauth 请求/响应模型。
框架契约:所有插件须有 models/pydantic.py(gen_models 的产物位置)——
网关契约校验按 plugins.<type>.models.pydantic → src.plugins.... → workers....
顺序探测,缺失时会以 src.plugins. 前缀二次执行插件包(父包导入链),
插件内自定义 SQLModel 表会在全局元数据中重复定义导致启动失败。
本插件模型由手写(等价 gen_models 产物),端点函数以类型注解直接绑定。
登录响应 LoginPostResponse 直接从 chongming_auth 导入(共享契约)。
"""
from pydantic import BaseModel, Field
class GithubAuthorizeGetResponse(BaseModel):
"""授权跳转 URL 响应"""
result: str = Field(..., description="success/fail")
authorize_url: str = Field(default="", description="GitHub 授权跳转 URL")
state: str = Field(default="", description="防 CSRF state(一次性,300s)")
expires_in: int = Field(default=300, description="state 有效期(秒)")
class GithubCallbackPostRequest(BaseModel):
"""GitHub 回调请求"""
code: str = Field(..., description="GitHub 回跳授权码")
state: str = Field(..., description="authorize 返回的 state")
class PhoneSendCodePostRequest(BaseModel):
"""发送验证码请求"""
phone: str = Field(..., description="手机号")
class PhoneSendCodePostResponse(BaseModel):
"""发送验证码响应"""
result: str = Field(..., description="success/fail")
message: str = Field(..., description="结果消息")
expires_in: int = Field(default=300, description="验证码有效期(秒)")
dev_code: str | None = Field(default=None, description="验证码(仅演示模式返回)")
class PhoneLoginPostRequest(BaseModel):
"""验证码登录请求"""
phone: str = Field(..., description="手机号")
code: str = Field(..., description="短信验证码")
+1
View File
@@ -0,0 +1 @@
from . import github # noqa: F401
+74
View File
@@ -0,0 +1,74 @@
"""GitHub OAuth 提供方对接(授权码模式)。
HTTP 调用独立成模块函数,便于测试替身(monkeypatch)注入。
"""
import logging
import httpx
logger = logging.getLogger("plugins.auth_oauth.github")
_TOKEN_URL = "https://github.com/login/oauth/access_token"
_USER_API = "https://api.github.com/user"
_USER_EMAILS_API = "https://api.github.com/user/emails"
async def exchange_code(client_id: str, client_secret: str, code: str,
redirect_uri: str) -> str:
"""授权码换 access_token,失败抛 RuntimeError(业务语义,调用方转 fail 响应)。"""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
_TOKEN_URL,
json={
"client_id": client_id,
"client_secret": client_secret,
"code": code,
"redirect_uri": redirect_uri,
},
headers={"Accept": "application/json"},
)
data = resp.json()
if "access_token" not in data:
detail = data.get("error_description") or data.get("error") or data
raise RuntimeError(f"GitHub 授权码换取失败: {detail}")
return data["access_token"]
async def fetch_user(access_token: str) -> dict:
"""拉取 GitHub 用户并归一化为建号 profile。
Returns:
{uid, username, email, full_name, avatar_url}
Raises:
RuntimeError: API 失败或拿不到邮箱(users.email 非空唯一,必须可得)。
"""
async with httpx.AsyncClient(timeout=10.0) as client:
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/vnd.github+json",
}
resp = await client.get(_USER_API, headers=headers)
if resp.status_code != 200:
raise RuntimeError(f"GitHub 用户信息获取失败: HTTP {resp.status_code}")
u = resp.json()
email = u.get("email")
if not email:
# 私密邮箱:走 /user/emails 取主邮箱
er = await client.get(_USER_EMAILS_API, headers=headers)
if er.status_code == 200 and isinstance(er.json(), list):
emails = er.json()
primary = next(
(e for e in emails if e.get("primary")), emails[0] if emails else None
)
email = primary.get("email") if primary else None
if not email:
raise RuntimeError("GitHub 账号未提供可用邮箱,无法创建账号")
return {
"uid": str(u["id"]),
"username": u.get("login") or f"github_{u['id']}",
"email": email,
"full_name": u.get("name"),
"avatar_url": u.get("avatar_url"),
}
+2
View File
@@ -0,0 +1,2 @@
from .github import github_authorize, github_callback # noqa: F401
from .phone import phone_send_code, phone_login # noqa: F401
+86
View File
@@ -0,0 +1,86 @@
"""GitHub OAuth 授权码模式端点(SPA 友好的 JSON 流程)。
流程:前端调 authorize 拿 authorize_url → 浏览器跳转 GitHub →
GitHub 回跳前端页(带 code+state)→ 前端调 callback 换平台 token。
"""
import logging
import secrets
from urllib.parse import urlencode
from chongming_worker import rpc_method
from ..models import GithubAuthorizeGetResponse, GithubCallbackPostRequest, LoginPostResponse
from ..providers import github as github_provider
from .. import store
logger = logging.getLogger("plugins.auth_oauth.github")
_STATE_TTL = 300 # state 有效期(秒),一次性消费防 CSRF
@rpc_method(
subject="auth_oauth.github_authorize",
method="GET",
summary="GitHub 授权登录:生成授权跳转 URL(state 存缓存防 CSRF)",
)
async def github_authorize() -> GithubAuthorizeGetResponse:
from ..global_var import cache, plugin_config
gh = plugin_config.get("github") or {}
client_id = gh.get("client_id", "")
if not client_id or client_id.startswith("your-"):
return GithubAuthorizeGetResponse(
result="fail", authorize_url="",
state="GitHub OAuth 未配置(config/workers/auth_oauth/config.toml 的 [workers.config.github])",
)
state = secrets.token_urlsafe(24)
cache.set(f"auth_oauth:state:{state}", "1", expire=_STATE_TTL)
params = urlencode({
"client_id": client_id,
"redirect_uri": gh.get("redirect_uri", ""),
"scope": gh.get("scope", "read:user user:email"),
"state": state,
})
return GithubAuthorizeGetResponse(
result="success",
authorize_url=f"https://github.com/login/oauth/authorize?{params}",
state=state,
)
@rpc_method(
subject="auth_oauth.github_callback",
method="POST",
summary="GitHub 授权回调:校验 state、换取用户信息,find-or-create 后签发平台 token",
)
async def github_callback(req: GithubCallbackPostRequest) -> LoginPostResponse:
from ..global_var import cache, db, plugin_config
if db is None or cache is None:
return store.fail_response("插件未初始化")
# state 一次性消费(防 CSRF / 重放)
state_key = f"auth_oauth:state:{req.state}"
if not cache.get(state_key):
return store.fail_response("state 无效或已过期,请重新发起授权")
cache.delete(state_key)
gh = plugin_config.get("github") or {}
try:
access_token = await github_provider.exchange_code(
gh.get("client_id", ""), gh.get("client_secret", ""),
req.code, gh.get("redirect_uri", ""),
)
profile = await github_provider.fetch_user(access_token)
except RuntimeError as e:
logger.warning("GitHub 回调失败: %s", e)
return store.fail_response(str(e))
user, message = await store.find_or_create_user(
db, "github", profile["uid"], profile
)
if user is None:
return store.fail_response(message)
return store.issue_login_response(user)
+82
View File
@@ -0,0 +1,82 @@
"""手机验证码登录端点(演示模式:验证码存共享缓存、默认随响应返回)。
生产接入真实短信通道后,将 send_code 中的 dev_code 分支关闭
(config: return_otp_in_response = false),验证码只经短信下发。
"""
import logging
import re
import secrets
from chongming_worker import rpc_method
from ..models import (
LoginPostResponse,
PhoneLoginPostRequest,
PhoneSendCodePostRequest,
PhoneSendCodePostResponse,
)
from .. import store
logger = logging.getLogger("plugins.auth_oauth.phone")
_OTP_TTL = 300 # 验证码有效期(秒)
_PHONE_RE = re.compile(r"^1\d{10}$") # 演示按中国大陆手机号校验
def _otp_key(phone: str) -> str:
return f"auth_oauth:otp:{phone}"
@rpc_method(
subject="auth_oauth.phone_send_code",
method="POST",
summary="手机验证码登录:下发验证码(演示模式:存缓存并随响应返回)",
)
async def phone_send_code(req: PhoneSendCodePostRequest) -> PhoneSendCodePostResponse:
from ..global_var import cache, plugin_config
if cache is None:
return PhoneSendCodePostResponse(result="fail", message="插件未初始化")
if not _PHONE_RE.match(req.phone):
return PhoneSendCodePostResponse(result="fail", message="手机号格式不正确")
code = f"{secrets.randbelow(1_000_000):06d}"
cache.set(_otp_key(req.phone), code, expire=_OTP_TTL)
# 生产环境在此接真实短信通道;演示模式打印并将验证码随响应返回
logger.info("[auth_oauth] 验证码已生成 phone=%s code=%s(演示模式,请勿用于生产)",
req.phone, code)
if plugin_config.get("return_otp_in_response", True):
return PhoneSendCodePostResponse(
result="success", message="验证码已发送(演示模式直接返回)", dev_code=code,
)
return PhoneSendCodePostResponse(result="success", message="验证码已发送")
@rpc_method(
subject="auth_oauth.phone_login",
method="POST",
summary="手机验证码登录:校验验证码,find-or-create 后签发平台 token",
)
async def phone_login(req: PhoneLoginPostRequest) -> LoginPostResponse:
from ..global_var import cache, db
if db is None or cache is None:
return store.fail_response("插件未初始化")
key = _otp_key(req.phone)
stored = cache.get(key)
if not stored or stored != req.code:
return store.fail_response("验证码错误或已过期")
cache.delete(key) # 一次性消费
user, message = await store.find_or_create_user(
db, "phone", req.phone,
profile={
"username": req.phone,
"email": f"{req.phone}@phone.local",
},
)
if user is None:
return store.fail_response(message)
return store.issue_login_response(user)
+114
View File
@@ -0,0 +1,114 @@
"""身份源插件共享逻辑:绑定查找、账号 find-or-create、平台 token 签发。
账号落点:
- 已有绑定 → 直接映射到既有 users 账号(老用户换身份源登录也是同一账号体系)
- 无绑定 → 新建 users 账号(密码为随机值,本插件身份源不走密码登录)+ 绑定记录
签发:
- chongming_auth.issue_token —— claims 契约与网关内置登录完全一致,
网关验签 / user_info 透传 / admin 判超管全部无缝生效
"""
import logging
import secrets
from datetime import datetime
from chongming_auth import LoginPostResponse, Users, issue_token
from chongming_config import get_config
from chongming_database import Database
from chongming_password import hash_password
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
import time
from .models import IdentityBinding
logger = logging.getLogger("plugins.auth_oauth")
# 演示身份源建号使用的占位密码(随机、不可用于密码登录)
_UNUSABLE_PASSWORD_BYTES = 32
async def _lookup_user(db: Database, provider: str,
provider_uid: str) -> Users | None:
"""按绑定关系定位既有账号;无绑定或账号已消失返回 None。"""
async with db.get_session() as session:
binding = (
await session.execute(
select(IdentityBinding).where(
IdentityBinding.provider == provider, # type: ignore
IdentityBinding.provider_uid == provider_uid, # type: ignore
)
)
).scalar_one_or_none()
if binding is None:
return None
user = (
await session.execute(select(Users).where(Users.id == binding.user_id)) # type: ignore
).scalar_one_or_none()
return user
async def _create_user_with_binding(
db: Database, provider: str, provider_uid: str, profile: dict
) -> None:
"""新建 users 账号 + 绑定记录(同一事务)。
profile: {username, email, full_name?, avatar_url?}
"""
async with db.get_session() as session:
user = Users(
username=str(profile.get("username", ""))[:50],
email=str(profile["email"]),
hashed_password=hash_password(secrets.token_urlsafe(_UNUSABLE_PASSWORD_BYTES)),
full_name=profile.get("full_name"),
avatar_url=profile.get("avatar_url"),
created_at=datetime.now(),
updated_at=datetime.now(),
is_active=True,
)
session.add(user)
await session.flush() # 取 user.id 供绑定外键
session.add(IdentityBinding(
user_id=user.id, provider=provider, provider_uid=provider_uid,
)) # type: ignore
await session.commit()
async def find_or_create_user(
db: Database, provider: str, provider_uid: str, profile: dict
) -> tuple[Users | None, str]:
"""按身份源 find-or-create 账号,返回 (user, message)。
user 非 None 即成功;唯一约束冲突(并发首登 / username·email 被占)时
回退到重查绑定,仍无则返回失败原因。
"""
user = await _lookup_user(db, provider, provider_uid)
if user is not None:
return user, ""
try:
await _create_user_with_binding(db, provider, provider_uid, profile)
except IntegrityError as e:
logger.warning("[%s] 建号冲突(%s),尝试复用既有绑定: %s", provider, provider_uid, e)
user = await _lookup_user(db, provider, provider_uid)
if user is None:
return None, "创建用户失败(用户名或邮箱已被占用),请改用其他方式登录"
return user, ""
def issue_login_response(user: Users) -> LoginPostResponse:
"""按平台契约签发 token 并组装与内置登录同 shape 的响应。"""
security = get_config()["security"]
token = issue_token(
user, security["secret_key"], security["algorithm"],
)
return LoginPostResponse(
result="success", token=token, message="登录成功", timestamp=time.time()
)
def fail_response(message: str) -> LoginPostResponse:
"""业务失败(不抛异常):result=fail + 原文案,保持与内置登录同 shape。"""
return LoginPostResponse(
result="fail", token="", message=message, timestamp=time.time()
)
+6 -4
View File
@@ -15,7 +15,7 @@
## 配置
在 `config/workers/doubao/config.toml` 和 `config/workers/config.toml` 中设置:
在 `config/workers/doubao/config.toml` 中设置:
```toml
[workers.config]
@@ -64,9 +64,11 @@ model = "doubao-seed-evolving" # 模型名称
## 实现细节
- 使用 `httpx` 异步 HTTP 客户端调用火山引擎 API
- 支持流式响应(streaming)中的思考模式 token
- API Key 优先从环境变量 `VOLC_ACCESSKEY` 读取
- 生命周期基于 `Plugin` 基类:`on_init` 按 `is_aicc` 分支创建 Ark(`AsyncArk`)或
AICC(`AsyncOpenAI` + `AsyncSecureHttpClient`)客户端并写入 `global_var`;
`on_cleanup` 释放客户端引用(无数据库)
- API Key 优先取配置 `api_key`,其次环境变量 `VOLC_ACCESSKEY`(缺失时插件初始化报错)
- 支持 AICC 安全通信模式(懒加载 SDK,避免非 AICC 环境导入失败)
- 超时时间默认 30 秒,在 `config.toml` 的 `defaults.timeout` 中配置
---
+63 -17
View File
@@ -1,60 +1,106 @@
"""
豆包视觉理解 Worker 插件
基于 Plugin 基类统一生命周期:on_init 中按配置创建 Ark / AICC 客户端,
on_cleanup 释放客户端引用(无数据库)。
"""
import os
import logging
from openai import AsyncOpenAI
from volcenginesdkarkruntime import AsyncArk
from chongming_worker import create_workers as _create_workers
from chongming_worker import auto_bind_models
from chongming_worker.plugin import Plugin
from .global_var import (
set_ark_client, set_aicc_client, set_is_aicc,
cleanup_all,
)
from .router import *
from .router import * # noqa: F401,F403 触发 @rpc_method 注册
# 自动绑定 gen_models 生成的 Pydantic 模型
auto_bind_models("doubao")
logger = logging.getLogger(__name__)
class DoubaoPlugin(Plugin):
"""豆包插件 — 统一生命周期。"""
type = "doubao"
db_name = None # 无数据库
auto_bind = True # 首次初始化时自动 auto_bind_models("doubao")
async def on_init(self):
"""首次 create_workers 时调用一次:初始化豆包客户端。"""
config = self.config
logger.info("豆包插件初始化开始 (count=%s, aicc=%s)",
config.get("count", 1), config.get("is_aicc", False))
async def create_workers(session, config, count=1, worker_manager=None):
"""
工厂函数:由 app 生命周期调用,创建 Worker 协程。
"""
is_aicc = config.get("is_aicc", False)
set_is_aicc(is_aicc)
api_key = config.get("api_key") or os.environ.get("VOLC_ACCESSKEY")
if not api_key:
logger.error("豆包插件初始化失败: 缺少 API_KEY")
raise ValueError("缺少 API_KEY,请在配置中设置 api_key 或环境变量 VOLC_ACCESSKEY")
model = config.get("model", "doubao-seed-evolving")
os.environ["DOUBAO_MODEL"] = model
logger.info("豆包模型: %s", model)
if is_aicc:
logger.info("豆包使用 AICC 安全通信模式")
# AICC 模式:懒加载,避免非 AICC 环境导入失败
try:
from bytedance.volcengine_aicc_sdk.secure_http_client import (
AICCConfig, AsyncSecureHttpClient,
)
except ImportError as e:
logger.error("AICC SDK 导入失败: %s,请确认已安装 bytedance_volcengine_aicc_sdk", e)
raise
base_url = config.get("base_url", "https://ark.cn-beijing.volces.com/api/v3")
os.environ["DOUBAO_BASE_URL"] = base_url
# ra_url 为远程证明(安全令牌)端点地址,可能与推理 base_url 不同
# 若不单独配置则默认使用 base_url 的域名 + /v1/security/token
ra_url = config.get("ra_url", base_url)
ak = config.get("ak", "")
sk = config.get("sk", "")
ra_service_name = config.get("ra_service_name", "AICC.ConfidentialInference")
ra_policy_id = config.get("ra_policy_id", "router_policy")
aicc_config = AICCConfig(ra_url=base_url, auth_token=api_key)
os.environ["DOUBAO_BASE_URL"] = base_url
logger.info("豆包 AICC base_url: %s, ra_url: %s, ra_service_name: %s, ra_policy_id: %s",
base_url, ra_url, ra_service_name, ra_policy_id)
aicc_config = AICCConfig(
ra_url=ra_url,
auth_token=api_key,
ak=ak,
sk=sk,
aicc_llm_service_name=ra_service_name,
policy_id=ra_policy_id,
)
client = AsyncOpenAI(
api_key=api_key,
base_url=base_url,
http_client=AsyncSecureHttpClient(aicc_config=aicc_config),
http_client=AsyncSecureHttpClient(aicc_config=aicc_config), # type: ignore
)
set_aicc_client(client)
logger.info("豆包 AICC 客户端已创建")
else:
logger.info("豆包使用标准 Ark SDK 模式")
# Ark 模式:使用 AsyncArk
base_url = config.get("base_url", "https://ark.cn-beijing.volces.com/api/v3")
os.environ["DOUBAO_BASE_URL"] = base_url
client = AsyncArk(base_url=base_url, api_key=api_key)
set_ark_client(client)
logger.info("豆包 Ark 客户端已创建 (base_url=%s)", base_url)
# 委托给通用运行时创建 Worker 任务
return _create_workers(session, config, count, worker_manager)
logger.info("豆包插件初始化完成")
async def cleanup(worker_item: str):
"""清理资源"""
async def on_cleanup(self):
"""cleanup 时调用:释放客户端引用。"""
cleanup_all()
print(f"{worker_item} 清理完毕")
logger.info("[doubao] 清理完成")
# 暴露给 app lifespan 的模块级入口(bound method,签名与旧写法完全一致)
plugin = DoubaoPlugin()
create_workers = plugin.create_workers
cleanup = plugin.cleanup
+29 -3
View File
@@ -1,8 +1,11 @@
import os
import logging
from chongming_worker import rpc_method
from ..models.pydantic import AnalyzePostRequest, AnalyzePostResponse
from ..global_var import get_client, get_is_aicc
logger = logging.getLogger("plugins.doubao.analyze")
@rpc_method(
subject="doubao.analyze",
method="POST",
@@ -10,10 +13,11 @@ from ..global_var import get_client, get_is_aicc
response_model=AnalyzePostResponse,
)
async def analyze(request: AnalyzePostRequest) -> AnalyzePostResponse:
print(f"[DEBUG] analyze called with prompt={request.prompt}, image_base64 length={len(request.image_base64)}")
logger.info("收到分析请求: prompt=%s, image_len=%d", request.prompt, len(request.image_base64))
client = get_client()
if client is None:
return AnalyzePostResponse(result="", error="客户端未初始化")
logger.error("豆包客户端未初始化(is_aicc=%s)", get_is_aicc())
return AnalyzePostResponse(result="", error="客户端未初始化,请检查插件启动日志确认 API_KEY 和 AICC 配置是否正确")
# 获取图片数据
image_base64 = request.image_base64
@@ -49,6 +53,28 @@ async def analyze(request: AnalyzePostRequest) -> AnalyzePostResponse:
**create_kwargs # type: ignore
)
result = response.choices[0].message.content # type: ignore
logger.info("分析完成: 结果长度=%d", len(result) if result else 0)
return AnalyzePostResponse(result=result) # type: ignore
except Exception as e:
return AnalyzePostResponse(result="", error=f"调用豆包API失败: {str(e)}")
error_msg = str(e)
# 针对 AICC 安全通道密钥缺失,给出明确提示
# KeyMissingError 可能被包装在 APIConnectionError 的 __cause__ 链中
is_key_missing = (
"KeyMissingError" in type(e).__name__
or "Can not get key" in error_msg
or any("KeyMissingError" in type(c).__name__ for c in [getattr(e, '__cause__', None), getattr(e, '__context__', None)] if c is not None)
)
if is_key_missing:
logger.error(
"AICC 安全通道密钥获取失败,请检查: "
"1) api_key 是否有效 "
"2) ra_url(安全令牌地址)是否正确 "
"3) APIGateway /v1/security/token 端点是否可达"
)
return AnalyzePostResponse(
result="",
error="AICC 安全通道初始化失败:无法获取加密密钥。"
"请检查 api_key 有效性及安全令牌服务 (/v1/security/token) 连通性。"
)
logger.error("调用豆包API失败: %s", e, exc_info=True)
return AnalyzePostResponse(result="", error=f"调用豆包API失败: {error_msg}")
+2 -1
View File
@@ -54,7 +54,8 @@ curl "http://localhost:8000/api/v1/calc/count"
1. **配置**:`config/workers/example/*.toml` — 定义每个端点的参数和响应结构
2. **模型**:`models/pydantic.py` — 由 `gen_models` 根据 TOML 自动生成
3. **路由**:`router/` 下每个文件实现一个 RPC 端点,使用 `@rpc_method` 装饰器
4. **工厂**:`__init__.py` 中的 `create_workers` 初始化数据库并启动 Worker
4. **入口**:`__init__.py` 的 `ExamplePlugin`(Plugin 基类)统一生命周期——`on_init` 初始化
数据库并发布到 global_var,`create_workers` / `cleanup` 为其 bound method
---
+29 -34
View File
@@ -1,13 +1,14 @@
"""
Example Worker 插件 — 演示 @rpc_method + 显式模型绑定用法
Example Worker 插件 — 演示 @rpc_method + Plugin 基类统一生命周期
本插件展示如何使用 chongming_worker 框架定义 RPC 端点。
本插件展示如何使用 chongming_worker 框架定义 RPC 端点,以及如何
基于 Plugin 基类编写风格统一的插件初始化 / 清理。
核心工作流:
1. 编写 config/workers/example/*.toml 配置文件
2. 执行 uv run gen_models 生成 Pydantic 模型
3. 在 __init__.py 中用 @rpc_method 定义业务函数
4. 显式导入模型并绑定到对应端点(替代 auto_bind_models)
3. 在 router/*.py 中用 @rpc_method 定义业务函数
4. 在 __init__.py 中用 Plugin 基类统一管理生命周期
关键约定:
- 模型命名: <Operation><Method>{Request|Response}
@@ -15,40 +16,34 @@ Example Worker 插件 — 演示 @rpc_method + 显式模型绑定用法
- 装饰器 subject 中的操作名 → 模型前缀(驼峰)
example.add → Add, example.subtract → Subtract
"""
from pathlib import Path
import logging
from chongming_worker import auto_bind_models
from chongming_worker.runtime import create_workers as _create_workers
from chongming_worker.plugin import Plugin
from .router import *
from .router import * # noqa: F401,F403 触发 @rpc_method 注册
auto_bind_models("example")
logger = logging.getLogger(__name__)
# ============================================================
# 工厂函数 — 供 app lifespan 动态加载
# ============================================================
# 框架通过 __import__ + getattr 获取此函数,
# 然后调用 create_workers(session, config, count, worker_manager=worker_manager)。
# 详见 src/chongming/app/__init__.py 的 lifespan。
class ExamplePlugin(Plugin):
"""Example 插件 — 统一生命周期。"""
type = "example"
db_name = "example.db" # 自动在 data_dir 下建 SQLite 库
auto_bind = True # 首次初始化时自动 auto_bind_models("example")
async def on_init(self):
"""首次 create_workers 时调用一次:初始化资源。"""
# 数据库已由基类自动初始化(self.db),写入全局变量供 router 使用
self.set_var("db", self.db)
logger.info("[example] 初始化完成")
async def on_cleanup(self):
"""cleanup 时调用:关闭自定义客户端等(数据库由基类处理)。"""
logger.info("[example] 清理完成")
async def create_workers(session, config, count=1, worker_manager=None):
"""插件入口 — 委托给 chongming_worker 运行时创建 Worker 协程"""
from .global_var import set_global_var
from chongming_database import Database
db_path = Path(config.get("data_dir", "./data")) / "example.db"
db = Database(db_path)
await db.initialize()
set_global_var("db", db)
return _create_workers(session, config, count, worker_manager)
async def cleanup(worker_item: str):
from .global_var import db
if db is not None:
await db.dispose()
print(f"{worker_item} 清理完毕")
# 暴露给 app lifespan 的模块级入口(bound method,签名与旧写法完全一致)
plugin = ExamplePlugin()
create_workers = plugin.create_workers
cleanup = plugin.cleanup
+41 -6
View File
@@ -1,22 +1,57 @@
"""Example 插件全局运行时状态 — 统一容器
约定:
- 模块只做"声明默认值",不产生任何导入期副作用
- 所有值统一在 create_workers 中通过 set_global_var 显式写入
- set_global_var 仅允许写入 _ALLOWED 白名单,拼写错误会立即抛错
- require() 用于取"必须已初始化"的值,未初始化时抛清晰错误
"""
from typing import Any
from .models import *
from chongming_database import Database
# 允许写入的全局变量白名单(防止拼写错误静默创建无用的新变量)
_ALLOWED = frozenset({"db"})
# 显式声明所有运行时状态(默认 None,运行时由 create_workers 写入)
db: Database | None = None
def set_global_var(name: str, value: Any) -> None:
"""
设置当前模块的全局变量。
白名单校验版 setter — 设置模块级运行时状态。
拼错变量名会立即抛 KeyError,而不是静默创建一个无用的新变量。
Args:
name: 变量名(字符串)
name: 必须在 _ALLOWED 白名单中的变量名
value: 任意值
"""
if name not in _ALLOWED:
raise KeyError(
f"global_var: 未知变量 '{name}'(允许: {sorted(_ALLOWED)})"
)
globals()[name] = value
__all__ = [
"set_global_var",
]
def require(name: str) -> Any:
"""
获取必须已初始化的全局变量,未初始化时抛清晰错误。
Args:
name: 必须在 _ALLOWED 白名单中的变量名
Returns:
对应全局变量的值
"""
if name not in _ALLOWED:
raise KeyError(f"global_var: 未知变量 '{name}'(允许: {sorted(_ALLOWED)})")
value = globals().get(name)
if value is None:
raise RuntimeError(
f"global_var: '{name}' 未初始化 —— 请确认插件 create_workers 已执行且配置正确"
)
return value
__all__ = sorted(_ALLOWED) + ["set_global_var", "require"]
+2
View File
@@ -15,6 +15,7 @@ from .pydantic import ExampleResponse
from .pydantic import HeartbeatMqttRequest
from .pydantic import MultiplyGetRequest
from .pydantic import MultiplyGetResponse
from .pydantic import StreamChatPostRequest
from .pydantic import SubtractGetRequest
from .pydantic import SubtractGetResponse
@@ -31,6 +32,7 @@ __all__ = [
"HeartbeatMqttRequest",
"MultiplyGetRequest",
"MultiplyGetResponse",
"StreamChatPostRequest",
"SubtractGetRequest",
"SubtractGetResponse",
]
+7 -2
View File
@@ -5,7 +5,7 @@
from __future__ import annotations
from typing import Any, Optional
from typing import Optional
from pydantic import BaseModel, Field
class AddGetRequest(BaseModel):
@@ -67,6 +67,11 @@ class MultiplyGetResponse(BaseModel):
operation: str = Field(default='multiply')
timestamp: Optional[float] = Field(default=0.0)
class StreamChatPostRequest(BaseModel):
"""模拟 AI 流式对话(SSE,逐 token 输出) — 请求体"""
prompt: str = Field(...)
tokens: int = Field(...)
class SubtractGetRequest(BaseModel):
"""Subtract two numbers — 请求体"""
a: float = Field(...)
@@ -80,4 +85,4 @@ class SubtractGetResponse(BaseModel):
# 所有响应模型的联合类型
ExampleResponse = AddPostResponse | AddGetResponse | DivideGetResponse | SubtractGetResponse | CountGetResponse | MultiplyGetResponse
ExampleResponse = MultiplyGetResponse | AddPostResponse | CountGetResponse | SubtractGetResponse | DivideGetResponse | AddGetResponse
+3 -1
View File
@@ -5,6 +5,7 @@ from .subtract import subtract
from .multiply import multiply
from .divide import divide
from .count import count_get
from .stream_chat import stream_chat
__all__ = [
@@ -15,5 +16,6 @@ __all__ = [
"subtract",
"multiply",
"divide",
"count_get"
"count_get",
"stream_chat"
]
+1 -4
View File
@@ -34,10 +34,8 @@ async def add(add_get_request:AddGetRequest) -> AddGetResponse:
)
async def add_post(add_post_request: AddPostRequest, session: zenoh.Session, user_info: dict | None = None) -> AddPostResponse:
"""加法运算(POST)— 通过 JSON Body 传入 a、b,返回它们的和"""
print("----------user_info-----------")
print(user_info)
async with Client(session=session) as client:
add_result = await client.call(
await client.call(
worker_type="example",
subject="example.add",
payload={
@@ -46,7 +44,6 @@ async def add_post(add_post_request: AddPostRequest, session: zenoh.Session, use
},
method="GET"
)
print(add_result)
return AddPostResponse(
result=add_post_request.a + add_post_request.b,
operation="add",
+1 -1
View File
@@ -2,7 +2,7 @@
MQTT 端点处理器 — 通过 MQTT 主题 chongming/example/add 调用
与 GET/POST 的 add 共用同一个 subject (example.add),
通过 _method="MQTT" 区分。
通过信封 meta.method="MQTT" 区分。
"""
import time
+34
View File
@@ -0,0 +1,34 @@
"""
流式端点示例 — @rpc_method 返回异步生成器,框架自动逐块多回复推送
网关在 TOML 中声明 stream = true 时,该端点以 SSE(text/event-stream)输出:
POST /api/v1/calc/stream_chat {"prompt": "你好", "tokens": 5}
响应: data: {"token": "..."} ... data: {"done": true}
约定:
- 插件侧写法:函数返回生成器(yield),框架逐块 query.reply() 推送
- {"done": true} 是业务结束标记(插件自定义),不是协议哨兵——
流式协议以生成器自然结束为流终止信号
- 中途异常时框架自动发送 {"error": ...} 收尾块
"""
import asyncio
import logging
from chongming_worker import rpc_method
logger = logging.getLogger("plugins.example.stream_chat")
@rpc_method(
subject="example.stream_chat",
method="POST",
summary="模拟 AI 流式对话(SSE,逐 token 输出)",
stream=True,
)
async def stream_chat(prompt: str = "你好", tokens: int = 10):
"""逐 token 模拟生成:把 return 改成 yield,框架即自动流式推送"""
logger.info("收到流式请求: prompt=%s, tokens=%d", prompt, tokens)
for i in range(tokens):
await asyncio.sleep(0.05)
yield {"token": f"{prompt}·{i + 1}", "index": i}
yield {"done": True}
+52
View File
@@ -0,0 +1,52 @@
"""Ip_sound_column Worker 插件
统一风格模板 — 基于 chongming_worker.plugin.Plugin 基类。
工作流:
1. 编辑 config/workers/ip_sound_column/*.toml 定义 RPC 端点
2. uv run gen_models config/workers/ip_sound_column src/plugins/ip_sound_column
3. 在 router/<操作>.py 中实现业务逻辑
4. 在 on_init / on_cleanup 中初始化 / 清理资源
"""
import logging
from chongming_worker.plugin import Plugin
from . import router # noqa: F401 触发 @rpc_method 注册
# from .routes import router # 可选: 自定义 FastAPI 路由(网关自动发现)
logger = logging.getLogger(__name__)
class IpSoundColumnPlugin(Plugin):
"""ip_sound_column 插件 — 统一生命周期。"""
type = "ip_sound_column"
db_name = "ip_sound_column.db" # 自动在 data_dir 下建 SQLite 库(不需要则置 None)
auto_bind = True # 首次初始化时自动 auto_bind_models(self.type)
async def on_init(self):
"""首次 create_workers 时调用一次:初始化资源、启动后台任务。
数据库已由基类 _bootstrap 自动初始化(self.db)。
"""
config = self.config
logger.info(
"[ip_sound_column] 初始化开始 (count=%s, ip=%s)",
config.get("count", 1),
config.get("ip", "unknown")
)
ip = config.get("ip", "unknown")
self.set_var("ip", ip)
logger.info("[ip_sound_column] 初始化完成")
async def on_cleanup(self):
"""cleanup 时调用:关闭自定义客户端等(后台任务与数据库由基类处理)。"""
logger.info("[ip_sound_column] 清理完成")
# 暴露给 app lifespan 的模块级入口(bound method,签名与旧写法完全一致)
_plugin = IpSoundColumnPlugin()
create_workers = _plugin.create_workers
cleanup = _plugin.cleanup
+41
View File
@@ -0,0 +1,41 @@
"""Ip_sound_column 插件全局运行时状态 — 统一容器
约定:
- 模块只做"声明默认值",不产生任何导入期副作用
- 所有值统一在 Plugin.on_init 中通过 set_global_var 显式写入
- set_global_var 仅允许写入 _ALLOWED 白名单,拼写错误会立即抛错
- require() 用于取"必须已初始化"的值,未初始化时抛清晰错误
"""
from typing import Any
from chongming_database import Database
# 允许写入的全局变量白名单(防止拼写错误静默创建无用的新变量)
_ALLOWED = frozenset({"ip"})
# 显式声明所有运行时状态(默认 None,运行时由 on_init 写入)
# db: Database | None = None
def set_global_var(name: str, value: Any) -> None:
"""白名单校验版 setter — 设置模块级运行时状态。"""
if name not in _ALLOWED:
raise KeyError(
f"global_var: 未知变量 '{name}'(允许: {sorted(_ALLOWED)})"
)
globals()[name] = value
def require(name: str) -> Any:
"""获取必须已初始化的全局变量,未初始化时抛清晰错误。"""
if name not in _ALLOWED:
raise KeyError(f"global_var: 未知变量 '{name}'(允许: {sorted(_ALLOWED)})")
value = globals().get(name)
if value is None:
raise RuntimeError(
f"global_var: '{name}' 未初始化 —— 请确认插件 create_workers 已执行且配置正确"
)
return value
__all__ = sorted(_ALLOWED) + ["set_global_var", "require"]
+14
View File
@@ -0,0 +1,14 @@
"""
模型导出 — ip_sound_column
自动生成,请勿手动修改
"""
from .pydantic import IpSoundColumnResponse
from .pydantic import TtsPostRequest
from .pydantic import TtsPostResponse
__all__ = [
"IpSoundColumnResponse",
"TtsPostRequest",
"TtsPostResponse",
]
+30
View File
@@ -0,0 +1,30 @@
"""
自动生成的 Pydantic 模型 — ip_sound_column
来源: config/workers/ip_sound_column/
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class TtsPostRequest(BaseModel):
"""ip 音柱 tts 播放 — 请求体"""
command: str = Field(...)
volume: int = Field(...)
play_count: int = Field(...)
queue: bool = Field(...)
sync_response: bool = Field(...)
play_interval: int = Field(...)
tts_pitch: int = Field(...)
tts_speed: int = Field(...)
tts_voice_name: str = Field(...)
tts_text: str = Field(...)
class TtsPostResponse(BaseModel):
"""ip 音柱 tts 播放 — 响应体"""
command: str = Field(...)
result: int = Field(...)
# 所有响应模型的联合类型
IpSoundColumnResponse = TtsPostResponse
+5
View File
@@ -0,0 +1,5 @@
"""Ip_sound_column RPC 端点"""
from . import tts
__all__ = ['tts']
+41
View File
@@ -0,0 +1,41 @@
"""RPC 端点: ip_sound_column.ping
模型由 gen_models 生成(编辑 config/workers/ip_sound_column/ping.toml 后重新生成)。
"""
import httpx
from chongming_worker.decorators import rpc_method
from ..models.pydantic import TtsPostRequest, TtsPostResponse
from ..global_var import require
@rpc_method(
subject="ip_sound_column.tts",
method="POST",
summary="tts 操作",
response_model=TtsPostResponse,
)
async def tts(request: TtsPostRequest) -> TtsPostResponse:
"""业务逻辑实现。"""
ip_addr: str = require("ip")
if ip_addr == "unknown":
raise RuntimeError("ip_sound_column: 未初始化 ip 地址,请检查配置")
tts_url = f"http://{ip_addr}/v1/api"
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
tts_url,
json=request.model_dump(),
headers={"Content-Type": "application/json"},
)
if resp.is_success:
return TtsPostResponse(**resp.json())
else:
raise RuntimeError(
f"ip_sound_column: tts 请求失败,状态码 {resp.status_code},响应内容: {resp.text}"
)
except httpx.RequestError as e:
raise RuntimeError(f"ip_sound_column: tts 请求异常: {str(e)}") from e