Compare commits
37
Commits
32f092e59c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4454393ad0 | ||
|
|
6a8c63b9f2 | ||
|
|
e0211105bd | ||
|
|
293254c61a | ||
|
|
d7636c4c3b | ||
|
|
c139b0c1d3 | ||
|
|
5ec90a5575 | ||
|
|
5ab68d6002 | ||
|
|
51aa93eefb | ||
|
|
5e5dee83df | ||
|
|
d6b385d2b1 | ||
|
|
faff9948b9 | ||
|
|
7bfd14d23c | ||
|
|
f723a8441b | ||
|
|
4fa066432d | ||
|
|
56dd4f0ebb | ||
|
|
3e1c568d04 | ||
|
|
74d115d2bb | ||
|
|
e9e029deaf | ||
|
|
1d768298e4 | ||
|
|
ba09b6768e | ||
|
|
54461cad18 | ||
|
|
f1e6c52311 | ||
|
|
ed8550ddc3 | ||
|
|
39b5ddd585 | ||
|
|
3663a3de83 | ||
|
|
96e8e366f5 | ||
|
|
83ab65c10b | ||
|
|
e43c25f5dc | ||
|
|
84addb71ff | ||
|
|
f024b6953e | ||
|
|
5f6b8c5269 | ||
|
|
e3265e40f0 | ||
|
|
48669eb2a9 | ||
|
|
e3552228b6 | ||
|
|
a12ceec221 | ||
|
|
4d6ad2eae2 |
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 颜色格式化器。
|
||||
@@ -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 在启动时调用。
|
||||
"""
|
||||
from .global_var import set_global_var, heartbeat_timeout as _default_timeout
|
||||
from chongming_database import Database
|
||||
type = "aibox_sophon_adapter"
|
||||
db_name = "aibox_sophon_adapter.db" # 自动在 data_dir 下建 SQLite 库
|
||||
auto_bind = True # 首次初始化时自动 auto_bind_models
|
||||
|
||||
# 读取心跳配置
|
||||
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)
|
||||
async def on_init(self):
|
||||
"""首次 create_workers 时调用一次:初始化数据库客户端、流控制器、后台任务。"""
|
||||
config = self.config
|
||||
from .global_var import set_global_var, heartbeat_timeout as _default_timeout
|
||||
from chongming_stream_manager import StreamManager
|
||||
|
||||
# 设定全局变量
|
||||
global heartbeat_timeout
|
||||
heartbeat_timeout = hb_timeout
|
||||
# 所有运行时配置均从 [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")
|
||||
|
||||
# 初始化数据库
|
||||
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)
|
||||
# 心跳配置
|
||||
heartbeat = config.get("heartbeat", {})
|
||||
hb_interval = heartbeat.get("interval", 5)
|
||||
hb_timeout = heartbeat.get("timeout", _default_timeout)
|
||||
check_interval = heartbeat.get("check_interval", 10)
|
||||
|
||||
db = Database(db_path)
|
||||
await db.initialize()
|
||||
# 告警保留配置
|
||||
alarm_retention = config.get("alarm_retention", {})
|
||||
alarm_max = alarm_retention.get("max_records", 0)
|
||||
|
||||
set_global_var("db", db)
|
||||
set_global_var("heartbeat_timeout", hb_timeout)
|
||||
set_global_var("heartbeat_interval", hb_interval)
|
||||
# 豆包 AI 分析开关
|
||||
doubao_cfg = config.get("doubao_analysis", {})
|
||||
doubao_enabled = doubao_cfg.get("enabled", True)
|
||||
|
||||
logger.info(
|
||||
f"[aibox_sophon_adapter] 已初始化: "
|
||||
f"心跳间隔={hb_interval}s, 超时={hb_timeout}s, 检查间隔={check_interval}s"
|
||||
)
|
||||
# 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 文本)
|
||||
|
||||
# 启动离线设备检查后台任务
|
||||
global _offline_check_task
|
||||
_offline_check_task = asyncio.create_task(_check_offline_devices(check_interval))
|
||||
# 告警转发配置
|
||||
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)
|
||||
|
||||
# 创建 Worker 副本
|
||||
return _create_workers(session, config, count, worker_manager)
|
||||
# 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(
|
||||
"[aibox_sophon_adapter] 已初始化: "
|
||||
f"心跳间隔={hb_interval}s, 超时={hb_timeout}s, 检查间隔={check_interval}s"
|
||||
)
|
||||
|
||||
# 后台任务由基类跟踪,cleanup 时自动取消
|
||||
self.spawn_task(_check_offline_devices(check_interval))
|
||||
|
||||
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
|
||||
|
||||
@@ -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}")
|
||||
@@ -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"
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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="授权描述")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,16 +85,47 @@ async def board_ping(request: BoardPingRequest, worker_id: str = "") -> dict:
|
||||
record.updated_at = now
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.debug(
|
||||
f"[Heartbeat] {board_id}: temp={request.BoardTemp}, "
|
||||
f"disk={request.HostDisk.Used}/{request.HostDisk.Total} kB, "
|
||||
f"mem={request.HostMemory[0]:.1f} kB ({request.HostMemory[1]*100:.1f}%), "
|
||||
f"medias={len(request.Medias)}, tasks={len(request.Tasks)}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Heartbeat] 处理心跳失败 (board_id={board_id}): {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
# ---- 以下为可选能力:失败只告警,不阻断心跳 ----
|
||||
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}, "
|
||||
f"disk={request.HostDisk.Used}/{request.HostDisk.Total} kB, "
|
||||
f"mem={request.HostMemory[0]:.1f} kB ({request.HostMemory[1]*100:.1f}%), "
|
||||
f"medias={len(request.Medias)}, tasks={len(request.Tasks)}"
|
||||
)
|
||||
|
||||
return {"received": True, "board_id": board_id}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
+436
-73
@@ -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,
|
||||
)
|
||||
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}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"[AlarmReport] 重复告警,已忽略: AlarmId={alarm_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[AlarmReport] 数据库写入失败: {e}")
|
||||
# 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,忽略告警")
|
||||
)
|
||||
|
||||
# 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', '')}"
|
||||
)
|
||||
|
||||
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="")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+4
-7
@@ -1,14 +1,13 @@
|
||||
from typing import Any
|
||||
|
||||
from chongming_config import get_config
|
||||
from chongming_jwt import JWT
|
||||
from chongming_database import Database
|
||||
from .models import *
|
||||
|
||||
def set_global_var(name: str, value: Any) -> None:
|
||||
"""
|
||||
设置当前模块的全局变量。
|
||||
|
||||
|
||||
Args:
|
||||
name: 变量名(字符串)
|
||||
value: 任意值
|
||||
@@ -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,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
@@ -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
@@ -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,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"]
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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 调用 |
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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="创建时间",
|
||||
)
|
||||
)
|
||||
@@ -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="短信验证码")
|
||||
@@ -0,0 +1 @@
|
||||
from . import github # noqa: F401
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
from .github import github_authorize, github_callback # noqa: F401
|
||||
from .phone import phone_send_code, phone_login # noqa: F401
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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` 中配置
|
||||
|
||||
---
|
||||
|
||||
+87
-41
@@ -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__)
|
||||
|
||||
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:
|
||||
raise ValueError("缺少 API_KEY,请在配置中设置 api_key 或环境变量 VOLC_ACCESSKEY")
|
||||
class DoubaoPlugin(Plugin):
|
||||
"""豆包插件 — 统一生命周期。"""
|
||||
|
||||
model = config.get("model", "doubao-seed-evolving")
|
||||
os.environ["DOUBAO_MODEL"] = model
|
||||
type = "doubao"
|
||||
db_name = None # 无数据库
|
||||
auto_bind = True # 首次初始化时自动 auto_bind_models("doubao")
|
||||
|
||||
if is_aicc:
|
||||
# AICC 模式:懒加载,避免非 AICC 环境导入失败
|
||||
from bytedance.volcengine_aicc_sdk.secure_http_client import (
|
||||
AICCConfig, AsyncSecureHttpClient,
|
||||
)
|
||||
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))
|
||||
|
||||
base_url = config.get("base_url", "https://ark.cn-beijing.volces.com/api/v3")
|
||||
os.environ["DOUBAO_BASE_URL"] = base_url
|
||||
is_aicc = config.get("is_aicc", False)
|
||||
set_is_aicc(is_aicc)
|
||||
|
||||
aicc_config = AICCConfig(ra_url=base_url, auth_token=api_key)
|
||||
client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
http_client=AsyncSecureHttpClient(aicc_config=aicc_config),
|
||||
)
|
||||
set_aicc_client(client)
|
||||
else:
|
||||
# Ark 模式:使用 AsyncArk
|
||||
base_url = config.get("base_url", "https://ark.cn-beijing.volces.com/api/v3")
|
||||
client = AsyncArk(base_url=base_url, api_key=api_key)
|
||||
set_ark_client(client)
|
||||
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")
|
||||
|
||||
# 委托给通用运行时创建 Worker 任务
|
||||
return _create_workers(session, config, count, worker_manager)
|
||||
model = config.get("model", "doubao-seed-evolving")
|
||||
os.environ["DOUBAO_MODEL"] = model
|
||||
logger.info("豆包模型: %s", model)
|
||||
|
||||
async def cleanup(worker_item: str):
|
||||
"""清理资源"""
|
||||
cleanup_all()
|
||||
print(f"{worker_item} 清理完毕")
|
||||
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")
|
||||
# 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")
|
||||
|
||||
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), # 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)
|
||||
|
||||
logger.info("豆包插件初始化完成")
|
||||
|
||||
async def on_cleanup(self):
|
||||
"""cleanup 时调用:释放客户端引用。"""
|
||||
cleanup_all()
|
||||
logger.info("[doubao] 清理完成")
|
||||
|
||||
|
||||
# 暴露给 app lifespan 的模块级入口(bound method,签名与旧写法完全一致)
|
||||
plugin = DoubaoPlugin()
|
||||
create_workers = plugin.create_workers
|
||||
cleanup = plugin.cleanup
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+43
-8
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
MQTT 端点处理器 — 通过 MQTT 主题 chongming/example/add 调用
|
||||
|
||||
与 GET/POST 的 add 共用同一个 subject (example.add),
|
||||
通过 _method="MQTT" 区分。
|
||||
通过信封 meta.method="MQTT" 区分。
|
||||
"""
|
||||
import time
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
模型导出 — ip_sound_column
|
||||
自动生成,请勿手动修改
|
||||
"""
|
||||
|
||||
from .pydantic import IpSoundColumnResponse
|
||||
from .pydantic import TtsPostRequest
|
||||
from .pydantic import TtsPostResponse
|
||||
|
||||
__all__ = [
|
||||
"IpSoundColumnResponse",
|
||||
"TtsPostRequest",
|
||||
"TtsPostResponse",
|
||||
]
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Ip_sound_column RPC 端点"""
|
||||
|
||||
from . import tts
|
||||
|
||||
__all__ = ['tts']
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user