[文档] 代码-文档一致性整理 - README 同步 v3 全量(2.1网络/3模块表/4表结构/5 API含people-clips/6.1历史标注/8部署systemd+Vue3+config/12进度); docs/DEPLOY.md 重写为 v3; 删除过时 start_ui.sh(Streamlit); start_edge.sh 注明 systemd 为准; 补提交 test_motion_notifier
This commit is contained in:
@@ -22,6 +22,14 @@
|
|||||||
- **E2E 验证(真机)**:素材 `Generic_ONVIF-001-20260822-110859-...mp4` → 分割 35 段(真实 event_id 25504-25538、duration 1-132s)→ 片段只分析(summary 真实:人物/动作)→ events(绝对时间 ts)→ NAS 同步 → `/api/ui/videos` 显示 motion_ 片段(字段契约不变:filename/event_start_time/camera_name/event_count/summary_json/compute_provider)→ `/api/ui/videos/<id>` 详情 events + people 正常 → `/api/proxy/frame` 帧图 200 OK
|
- **E2E 验证(真机)**:素材 `Generic_ONVIF-001-20260822-110859-...mp4` → 分割 35 段(真实 event_id 25504-25538、duration 1-132s)→ 片段只分析(summary 真实:人物/动作)→ events(绝对时间 ts)→ NAS 同步 → `/api/ui/videos` 显示 motion_ 片段(字段契约不变:filename/event_start_time/camera_name/event_count/summary_json/compute_provider)→ `/api/ui/videos/<id>` 详情 events + people 正常 → `/api/proxy/frame` 帧图 200 OK
|
||||||
- **关键认知**:SS 事件 duration=0 仅在动作**进行中**(结束时必为正数,实测 id=25538 从 0→2s),故只分割已结束事件(start+duration ≤ now+grace),进行中的等下一轮。
|
- **关键认知**:SS 事件 duration=0 仅在动作**进行中**(结束时必为正数,实测 id=25538 从 0→2s),故只分割已结束事件(start+duration ≤ now+grace),进行中的等下一轮。
|
||||||
- **部署注意**:Oracle fam-edge 由 **systemd `fam-edge.service` 守护**(Restart=always),代码部署后必须 `sudo systemctl restart fam-edge`(手动 setsid 会与守护打架导致端口冲突)。
|
- **部署注意**:Oracle fam-edge 由 **systemd `fam-edge.service` 守护**(Restart=always),代码部署后必须 `sudo systemctl restart fam-edge`(手动 setsid 会与守护打架导致端口冲突)。
|
||||||
|
|
||||||
|
## 2026-08-22 清数据重提取 + 人物管理重设计 + 文档同步(commits 4cf4fc4/6a29e88/9664459)
|
||||||
|
|
||||||
|
- **清数据重提取**(用户决策:整段提取旧数据全删,按运动视频重新提取):Oracle videos(364)/events(1687)/people(44)/model_calls/service_activity 全清 + motion_clips 清空(保留 ss_motion_events 93 条 + 素材 + 配置,先备份);NAS sync_* 四表全清;重启两端自动重提取(8/22 素材→运动片段;8/15-21 历史素材无运动事件→0 段)
|
||||||
|
- **时间轴过滤**:历史素材"分割 0 段"空会话淹没时间轴 → db_layer.get_sync_videos/get_sync_stats 加内容过滤(`LEFT(filename,7)='motion_'` OR 有事件),前端契约不变;**踩坑:pymysql execute 用 % 做占位符,SQL 字面量 'motion_%' 的 % 报 "unsupported format character" 500 → 改 LEFT 判断**
|
||||||
|
- **人物管理重设计**:`GET /api/ui/people/clips?label=`(db_layer.get_sync_people_clips:按 label/canonical_name 匹配 person_list_json → 关联运动片段,含 first_ts/clip_events);PersonCard.vue 新增「运动片段」区块(缩略图/时间/摘要/事件数,点击跳 `/timeline?video=`);Timeline.vue 支持 query 定位
|
||||||
|
- **文档同步**:README 更新 2.1 网络要点/2.2 拓扑(Streamlit→Vue3)/3.1-3.3 模块表(poll 主路径+people clips)/4.1 表清单(motion_event_id/ss_motion_events)/5.1-5.2 API 表(people/clips+frame/avatar)/6.1 历史标注/8 部署(systemd+Vue3+motion_segment config)/12 进度;docs/DEPLOY.md 重写为 v3;已 push
|
||||||
|
- **验证**:/api/ui/videos 全为 motion_ 片段(15 条);/api/ui/people 3 身份;people/clips 返回媳妇 3 个片段;前端 dist 已构建部署
|
||||||
| Tailscale | Oracle ↔ NAS | 100.74 ↔ 100.70 | ⚠️ 待修复 | Tailscale 运行但端口不通,当前用公网IP |
|
| Tailscale | Oracle ↔ NAS | 100.74 ↔ 100.70 | ⚠️ 待修复 | Tailscale 运行但端口不通,当前用公网IP |
|
||||||
| FAM-UI | NAS | 0.0.0.0:8501 | ✅ 运行中 | Streamlit 1.61.1, HTTP 200, health=ok |
|
| FAM-UI | NAS | 0.0.0.0:8501 | ✅ 运行中 | Streamlit 1.61.1, HTTP 200, health=ok |
|
||||||
|
|
||||||
|
|||||||
187
README.md
187
README.md
@@ -63,14 +63,14 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA N
|
|||||||
|
|
||||||
| 节点 | 角色 | 硬件 | IP | 服务与端口 |
|
| 节点 | 角色 | 硬件 | IP | 服务与端口 |
|
||||||
|------|------|------|-----|-----------|
|
|------|------|------|-----|-----------|
|
||||||
| Synology NAS | FAM-Core + FAM-UI + 数据库 | DS220+ (Geminilake), DSM 7 | 家庭局域网 192.168.50.64 / Tailscale 100.70.234.39 | FAM-Core :8000, FAM-UI :8501, MariaDB :3306, Surveillance Station |
|
| Synology NAS | FAM-Core + FAM-UI + 数据库 | DS220+ (Geminilake), DSM 7 | 家庭局域网 192.168.50.64 / Tailscale 100.70.234.39 | FAM-Core :8000, FAM-UI(Vue3 构建产物,由 FAM-Core 托管), MariaDB :3306, Surveillance Station :5000 |
|
||||||
| Oracle Cloud | FAM-Edge | Ampere A1 2C12G ARM64(无 GPU), Ubuntu 20.04 | 公网 129.146.203.203 / Tailscale 100.74.137.126 | FAM-Edge :5000, Ollama :11434(仅本地) |
|
| Oracle Cloud | FAM-Edge | Ampere A1 2C12G ARM64(无 GPU), Ubuntu 20.04 | 公网 129.146.203.203 / Tailscale 100.74.137.126 | FAM-Edge :5000(systemd 守护), Ollama :11434(仅本地) |
|
||||||
| 家庭网络 | 用户入口 | 普通终端 | 192.168.50.0/24 | 浏览器访问 `http://192.168.50.64:8501` |
|
| 家庭网络 | 用户入口 | 普通终端 | 192.168.50.0/24 | 浏览器访问 `http://192.168.50.64:8000` |
|
||||||
|
|
||||||
**网络要点(推送模式)**:
|
**网络要点(推送模式)**:
|
||||||
- 服务间通信只有两条出站:**NAS → Oracle 公网 IP:5000**(① HTTP 上传视频 ② `POST /api/ss/motion` 推送运动侦测事件)。Edge 不需要反向访问 NAS(无视频拉取)
|
- 服务间通信只有两条出站:**NAS → Oracle 公网 IP:5000**(① `GET /api/oracle/sync` 拉增量 + `POST /api/oracle/people/correct` 命名回推 ② `POST /api/ss/motion` 推送运动侦测事件)。Edge 不需要反向访问 NAS
|
||||||
- **Surveillance Station → NAS 本机 Webhook**:摄像头动作经 SS「行動規則/Webhook」实时 `POST :8000/api/ss/webhook`(同机 127.0.0.1,无需外网),由 FAM-Core 映射后推送 Oracle。这是 NAS 入站,非 Edge 反向访问
|
- **运动事件获取(NAS 轮询驱动)**:FAM-Core MotionNotifier 每 60s 轮询本机 Surveillance Station 事件列表 API(`SYNO.SurveillanceStation.EventCenter.Event`),增量推送 Oracle。`/api/ss/webhook` 端点在代码中保留为可选低延迟补充(SS 行動規則 Webhook 未配置则不触发)
|
||||||
- Oracle 端 Ollama 端口 11434 不对外暴露,聊天请求经 FAM-Edge `/api/edge/chat` 代理转发
|
- Oracle 端 Ollama 端口 11434 不对外暴露,聊天请求经 FAM-Edge `/api/edge/chat/ask` 代理转发
|
||||||
- Tailscale 两节点已安装在线,但 NAS tailscaled 为 userspace 模式且防火墙端口不通,暂走公网 IP
|
- Tailscale 两节点已安装在线,但 NAS tailscaled 为 userspace 模式且防火墙端口不通,暂走公网 IP
|
||||||
|
|
||||||
### 2.2 部署拓扑与数据流(新架构 v2:Oracle 分析 + NAS 镜像)
|
### 2.2 部署拓扑与数据流(新架构 v2:Oracle 分析 + NAS 镜像)
|
||||||
@@ -160,43 +160,44 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA N
|
|||||||
|
|
||||||
### 3.1 FAM-Core(NAS 端)
|
### 3.1 FAM-Core(NAS 端)
|
||||||
|
|
||||||
> NAS 不再处理视频,仅作管理后台。唯一常驻后台线程是 Oracle-Sync。
|
> NAS 不再处理视频,仅作管理后台。常驻后台线程:Oracle-Sync(增量镜像)+ MotionNotifier(轮询 SS 运动事件)。
|
||||||
|
|
||||||
| 模块 | 文件 | 职责 |
|
| 模块 | 文件 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| Oracle-Sync | `oracle_sync/oracle_sync.py` | 唯一后台线程:每 30 分钟 `GET /api/oracle/sync?since=<cursor>&token=` 拉增量 → upsert 到 `sync_videos`/`sync_events`/`sync_people` → 推进 `sync_cursor`;`push_name_correct()` 回推命名校正;`trigger_now()` 立即同步 |
|
| Oracle-Sync | `oracle_sync/oracle_sync.py` | 后台线程:每 30 分钟 `GET /api/oracle/sync?since=<cursor>&token=` 拉增量 → upsert 到 `sync_videos`/`sync_events`/`sync_people` → 推进 `sync_cursor`;`push_name_correct()` 回推命名校正;`trigger_now()` 立即同步 |
|
||||||
| Chat-Handler | `chat_handler/chat_handler.py` | `/api/chat/ask` 查 `sync_events` 拼上下文 → 经 Oracle `/api/edge/chat/ask` 问答编排(Gemini→NVIDIA→本地 Ollama 兜底)→ 写 chat_history |
|
| Chat-Handler | `chat_handler/chat_handler.py` | `/api/chat/ask` 查 `sync_events` 拼上下文 → 经 Oracle `/api/edge/chat/ask` 问答编排(Gemini→NVIDIA→本地 Ollama 兜底)→ 写 chat_history |
|
||||||
| Member-Manager | `member_manager/member_manager.py` | `/api/member/unnamed` / `/api/member/list` / `/api/member/name` / `/api/member/merge`;命名/合并回推 Oracle 并即时拉回本地镜像 |
|
| Member-Manager | `member_manager/member_manager.py` | `/api/member/unnamed` / `/api/member/list` / `/api/member/name` / `/api/member/merge`;命名/合并回推 Oracle 并即时拉回本地镜像 |
|
||||||
| MotionNotifier | `motion_notifier/motion_notifier.py` | 运动事件映射/发送服务:**轮询已关闭**(`poll_enabled=false`);启动时一次性拉取 SS 摄像头「名→id」映射并以 config 兜底;提供 `build_event_from_webhook()` 将 SS Webhook 字段归一化为事件 |
|
| MotionNotifier | `motion_notifier/motion_notifier.py` | **轮询主路径**(`poll_enabled=true`):每 `poll_interval_sec`(60s) 查 SS `EventCenter.Event.List`(camera_ids/event_types=10)→ 增量推送 Oracle `/api/ss/motion`;游标存 MariaDB(重启续用/补推停机期间事件,失败批次不前进);心跳线程定期空 POST 证明链路存活;启动时拉取 SS 摄像头「名→id」映射并配置兜底 |
|
||||||
| Motion-Webhook | `motion_bp.py` | `POST /api/ss/webhook` 接收 SS Webhook(兼容 JSON/表单/单条/数组)→ 映射 → 推送 Oracle;`GET /api/ss/status` 查询状态 |
|
| Motion-BP | `motion_bp.py` | `POST /api/ss/webhook` 可选低延迟补充(SS 行動規則 Webhook 未配置则不触发,兼容 JSON/表单/单条/数组)→ 映射 → 推送 Oracle;`GET /api/ss/status` 查询状态 |
|
||||||
| 公共层 | `db_layer.py` / `config_loader.py` / `logger.py` | PyMySQL 连接(unix_socket);同步镜像 CRUD;文件日志 |
|
| UI-API | `ui_api.py` | `/api/ui/*` 只读接口(videos/stats/people/people-clips/attention-events/named-members/model-stats/service-status),供 Vue 前端渲染 |
|
||||||
|
| 公共层 | `db_layer.py` / `config_loader.py` / `logger.py` | PyMySQL 连接(unix_socket);同步镜像 CRUD;`get_sync_people_clips()` 人物→运动片段查询;文件日志 |
|
||||||
|
|
||||||
> 已删除:Task-Scheduler / Dispatcher / Poller / Event-Receiver / Video-Server(视频上传、切片、抽帧、关键帧落盘等职责全部迁移至 Oracle 端,NAS CPU 占用大幅降低)。
|
> 已删除:Task-Scheduler / Dispatcher / Poller / Event-Receiver / Video-Server(视频上传、切片、抽帧、关键帧落盘等职责全部迁移至 Oracle 端,NAS CPU 占用大幅降低)。
|
||||||
|
|
||||||
### 3.2 FAM-Edge(Oracle 端)
|
### 3.2 FAM-Edge(Oracle 端)
|
||||||
|
|
||||||
> 整视频分析,不切片、不抽帧、不依赖 OpenCV 人脸。
|
> 整段素材按运动事件分割运动片段,**只分析运动片段**(不再整段送云端)。
|
||||||
|
|
||||||
| 模块 | 文件 | 职责 |
|
| 模块 | 文件 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| API-Gateway | `api_gateway/api_gateway.py` | `GET /api/oracle/sync`(增量拉取,since+token 校验);`POST /api/oracle/people/correct`(命名校正);`POST /api/edge/chat/ask`(问答编排);`POST /api/ss/motion`(运动事件接收,token 校验,落库 `ss_motion_events`);`GET /health` |
|
| API-Gateway | `api_gateway/api_gateway.py` | `GET /api/oracle/sync`(增量拉取,since+token 校验);`POST /api/oracle/people/correct`(命名校正);`POST /api/edge/chat/ask`(问答编排);`POST /api/ss/motion`(运动事件接收,token 校验,落库 `ss_motion_events` + 刷新心跳);`GET /api/oracle/frame|avatar`(帧/头像);`GET /health` |
|
||||||
| Video-Queue | `video_queue.py` | 生产-消费队列:生产者 30s 轮询 rclone 同步落地目录登记整段素材入队(含重启恢复);消费者(`max_concurrent` 个线程)取队列调 Video-Processor;模型超时 = 原配置 ×`timeout_multiplier`;失败重试上限 `max_retries` |
|
| Video-Queue | `video_queue.py` | 生产-消费队列:生产者 30s 轮询 rclone 同步落地目录登记整段素材入队(含重启恢复);消费者(`max_concurrent` 个线程)取队列调 Video-Processor;素材分割出的运动片段自动入队;模型超时 = 原配置 ×`timeout_multiplier`;失败重试上限 `max_retries` |
|
||||||
| Video-Processor | `video_processor.py` | **素材→分割/片段→分析**双分支:素材按 `ss_motion_events` 已结束运动事件 ffmpeg 分割运动片段(`-c:v copy -c:a aac`,幂等去重),片段只送云端 VLM 分析;按 `vision_order` 调适配器;首个成功即落库 Oracle `videos`+`events`+`people`;全失败标 `failed` |
|
| Video-Processor | `video_processor.py` | **素材→分割/片段→分析**双分支:素材按 `ss_motion_events` 已结束运动事件 ffmpeg 分割运动片段(`-c:v copy -c:a aac` 保留音频,`motion_event_id` 幂等),片段只送云端 VLM 分析;按 `vision_order` 调适配器;首个成功即落库 Oracle `videos`+`events`+`people`;全失败标 `failed` |
|
||||||
| Person-Service | `person_service.py` | 汇总全量人物 → LLM 合并为规范名 → `set_canonical`;生成 `known_members_context` 回灌视频提示;manual 命名优先不被覆盖 |
|
| Person-Service | `person_service.py` | 汇总全量人物 → LLM 合并为规范名 → `set_canonical`;生成 `known_members_context` 回灌片段提示;manual 命名优先不被覆盖 |
|
||||||
| OracleDB | `oracle_db.py` | SQLite:videos / events / people / sync_cursor / ss_motion_events;`get_sync_delta(since)` 增量导出;`record_motion_events` / `has_motion_in_range_local`(运动事件幂等落库与窗口查询) |
|
| OracleDB | `oracle_db.py` | SQLite:videos(含 `motion_event_id`/`camera_id` 列)/ events / people / sync_cursor / ss_motion_events;`get_sync_delta(since)` 增量导出;`record_motion_events` / `get_motion_events_in_range`(已结束运动事件窗口查询,分割用)/ `has_motion_in_range_local` / 心跳 |
|
||||||
| Model-Adapters | `model_adapters/` | `BaseModelAdapter.analyze_video(video_path, known_members_context, event_start_time)`;Gemini(Files API 整视频)/ NVIDIA(整视频 `video_url`,`num_frames=128`)/ Ollama(纯文本,不参与视频) |
|
| Model-Adapters | `model_adapters/` | `BaseModelAdapter.analyze_video(video_path, known_members_context, event_start_time)`;Gemini(Files API)/ NVIDIA(整视频 `video_url`)/ Ollama(纯文本,不参与视频)——输入均为运动片段 |
|
||||||
| QA-Orchestrator | `qa.py` | 遍历所有适配器 `chat()`,Gemini→NVIDIA→Ollama 三级降级(仅问答) |
|
| QA-Orchestrator | `qa.py` | 遍历所有适配器 `chat()`,Gemini→NVIDIA→Ollama 三级降级(仅问答) |
|
||||||
|
|
||||||
### 3.3 FAM-UI(NAS 端)
|
### 3.3 FAM-UI(NAS 端)
|
||||||
|
|
||||||
Streamlit 应用(`fam-ui/src/app.py`),侧边栏切换页面(均读本地同步镜像):
|
Vue3 + Vite + Tailwind SPA(`fam-ui/src/views/*.vue`,构建产物 `fam-ui/dist/` 由 FAM-Core `static_app.py` 托管,Vue Router history 模式),页面均读本地同步镜像:
|
||||||
|
|
||||||
| 页面 | 功能 |
|
| 页面 | 功能 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 🕒 事件时间轴 | 视频会话列表(按处理后时间倒序)+ 选中会话的事件时间线(时间点 + 描述 + 人物/关注徽章,无帧图) |
|
| 🕒 事件时间轴 | 运动片段会话列表(`motion_*.mp4`,只显示有内容的会话)+ 选中会话的事件时间线(时间点 + 描述 + 人物/关注徽章 + 事件帧图);支持 `?video=<id>` 定位跳转 |
|
||||||
|
| 👤 人物管理 | 按规范名/标签聚合,命名/合并(回推 Oracle);**人物卡含「运动片段」区块**(该人物出现过的运动片段:缩略图/时间/摘要/事件数,点击跳时间轴) |
|
||||||
| 💬 AI 对话 | 输入框 + 调 `/api/chat/ask`;按 queried_person 预设快捷提问 |
|
| 💬 AI 对话 | 输入框 + 调 `/api/chat/ask`;按 queried_person 预设快捷提问 |
|
||||||
| 📝 对话历史 | chat_history 倒序展示 |
|
| 📝 对话历史 | chat_history 倒序展示 |
|
||||||
| 👤 人物管理 | 按规范名/标签聚合,命名/合并(回推 Oracle);不再展示帧照片 |
|
|
||||||
| 📈 统计图表 | 模型来源占比 / 关注事件 / 同步状态 |
|
| 📈 统计图表 | 模型来源占比 / 关注事件 / 同步状态 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -213,19 +214,20 @@ Streamlit 应用(`fam-ui/src/app.py`),侧边栏切换页面(均读本地
|
|||||||
|
|
||||||
| 表 | 用途 | 关键字段 |
|
| 表 | 用途 | 关键字段 |
|
||||||
|----|------|---------|
|
|----|------|---------|
|
||||||
| `videos` | 视频会话(每视频 1 行) | id, filename(UNIQUE), camera_name, status, summary_json, events_json, people_json, compute_provider, event_start_time, updated_at |
|
| `videos` | 视频会话(整段素材 + 运动片段均在此表) | id, filename(UNIQUE), camera_name, status, summary_json, events_json, people_json, compute_provider, event_start_time, duration_sec, **motion_event_id**(运动片段关联的 SS 事件 id), **camera_id**, updated_at |
|
||||||
| `events` | 视频内时间点事件 | id, video_id, ts, description, person_list_json, is_attention_event |
|
| `events` | 视频内时间点事件 | id, video_id, ts, description, person_list_json, person_appearances_json, is_attention_event |
|
||||||
| `people` | 规范人物(Oracle 维护) | id, label(UNIQUE), canonical_name, appearances, source(llm/manual) |
|
| `people` | 规范人物(Oracle 维护) | id, label(UNIQUE), canonical_name, appearances, source(llm/manual), features_json, display_uid |
|
||||||
| `sync_cursor` | 同步游标 | key, value(上次 server_time) |
|
| `ss_motion_events` | NAS 推送的运动事件(分割素材的依据) | id, event_id(UNIQUE), camera_id, event_type(10=运动), start_time(epoch), duration, thumbnail_url, received_at |
|
||||||
|
| `sync_cursor` | 同步游标 + 心跳 | key, value(sync_cursor=上次 server_time;motion_heartbeat_at=推送链路心跳) |
|
||||||
|
|
||||||
**NAS(MariaDB,同步镜像,`scripts/ddl.sql`)**
|
**NAS(MariaDB,同步镜像,`scripts/ddl.sql`)**
|
||||||
|
|
||||||
| 表 | 用途 | 关键字段 |
|
| 表 | 用途 | 关键字段 |
|
||||||
|----|------|---------|
|
|----|------|---------|
|
||||||
| `sync_videos` | 视频会话镜像(对齐 Oracle videos) | id, filename, camera_name, status, summary_json, events_json, people_json, compute_provider, processed_at |
|
| `sync_videos` | 视频会话镜像(对齐 Oracle videos,含运动片段) | id, filename, camera_name, status, summary_json, events_json, people_json, compute_provider, processed_at |
|
||||||
| `sync_events` | 事件镜像(对齐 Oracle events) | id, video_id, ts, description, person_list_json, is_attention_event |
|
| `sync_events` | 事件镜像(对齐 Oracle events) | id, video_id, ts, description, person_list_json, is_attention_event |
|
||||||
| `sync_people` | 人物镜像(对齐 Oracle people) | id, label, canonical_name, appearances, source |
|
| `sync_people` | 人物镜像(对齐 Oracle people) | id, label, canonical_name, appearances, source, features_json, display_uid |
|
||||||
| `sync_cursor` | 同步游标 | key='last_since', value |
|
| `sync_cursor` | 同步游标 + 运动游标 | key='last_since';key='motion_last_event_id'(MotionNotifier 增量游标) |
|
||||||
| `chat_history` | AI 问答记录 | chat_id, user_question, ai_answer, context_summary, queried_date, queried_person |
|
| `chat_history` | AI 问答记录 | chat_id, user_question, ai_answer, context_summary, queried_date, queried_person |
|
||||||
|
|
||||||
### 4.2 表关系
|
### 4.2 表关系
|
||||||
@@ -283,6 +285,9 @@ chat_history 独立表(问答上下文摘要留存)
|
|||||||
|
|
||||||
**POST /api/oracle/people/correct** — 命名校正回推:`{"label":"人物A","canonical_name":"汤圆","token":...}`(manual 优先,不被 LLM 覆盖)→ `{"status":"ok"}`
|
**POST /api/oracle/people/correct** — 命名校正回推:`{"label":"人物A","canonical_name":"汤圆","token":...}`(manual 优先,不被 LLM 覆盖)→ `{"status":"ok"}`
|
||||||
**POST /api/edge/chat/ask** — 智能问答编排(FAM-Core Chat-Handler 调用):请求 `{"prompt","max_tokens"}` → 响应 `{"answer","provider"}`;内部按 Gemini → NVIDIA → 本地 Ollama 顺序,仅两云端都失败才用本地兜底
|
**POST /api/edge/chat/ask** — 智能问答编排(FAM-Core Chat-Handler 调用):请求 `{"prompt","max_tokens"}` → 响应 `{"answer","provider"}`;内部按 Gemini → NVIDIA → 本地 Ollama 顺序,仅两云端都失败才用本地兜底
|
||||||
|
**POST /api/ss/motion** — 运动事件接收(NAS MotionNotifier 推送):`{"token", "events":[{event_id, camera_id, event_type, start_time, duration, thumbnail_url}]}` → 幂等落库 `ss_motion_events` + 刷新心跳;`events: []` 空数组即心跳
|
||||||
|
**GET /api/oracle/frame** — 事件帧图:`?video_id=&ts=&w=` → jpeg(`ts` 为绝对时间,`frame_service` 按 `ts − event_start_time` 偏移从视频文件取帧)
|
||||||
|
**GET /api/oracle/avatar** — 人物头像:`?label=&w=` → jpeg(从该人物候选事件 `person_appearances` bbox 裁剪)
|
||||||
**GET /health** — 服务状态(含已处理视频数)
|
**GET /health** — 服务状态(含已处理视频数)
|
||||||
|
|
||||||
### 5.2 FAM-Core(NAS :8000)
|
### 5.2 FAM-Core(NAS :8000)
|
||||||
@@ -290,21 +295,32 @@ chat_history 独立表(问答上下文摘要留存)
|
|||||||
| 端点 | 方法 | 说明 |
|
| 端点 | 方法 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `/health` | GET | 服务健康 |
|
| `/health` | GET | 服务健康 |
|
||||||
| `/api/status` | GET | Oracle-Sync 同步状态(running / last_sync_at / last_error / cursor / last_count) |
|
| `/api/status` | GET | Oracle-Sync + MotionNotifier 状态(sync running/cursor;motion poll_enabled/pushed_total/heartbeat) |
|
||||||
| `/api/chat/ask` | POST | 用户问答:`{"question","queried_person","queried_date"}` → `{"answer","context_summary","chat_id"}`(上下文来自 sync_events) |
|
| `/api/chat/ask` | POST | 用户问答:`{"question","queried_person","queried_date"}` → `{"answer","context_summary","chat_id"}`(上下文来自 sync_events) |
|
||||||
| `/api/chat/history` | GET | 对话历史(`?date=` 或 `?person=&limit=`) |
|
| `/api/chat/history` | GET | 对话历史(`?date=` 或 `?person=&limit=`) |
|
||||||
| `/api/member/unnamed` | GET | 未命名人物列表(label / 出现次数 / 首见时间) |
|
| `/api/member/unnamed` | GET | 未命名人物列表(label / 出现次数 / 首见时间) |
|
||||||
| `/api/member/list` | GET | 全部人物(label + canonical_name + 是否命名) |
|
| `/api/member/list` | GET | 全部人物(label + canonical_name + 是否命名) |
|
||||||
| `/api/member/name` | POST | 命名:`{"label","canonical_name"}` → 回推 Oracle 并即时拉回本地镜像 |
|
| `/api/member/name` | POST | 命名:`{"label","canonical_name"}` → 回推 Oracle 并即时拉回本地镜像 |
|
||||||
| `/api/member/merge` | POST | 合并:`{"source","target"}` → 将 source 并入 target 身份(统一 canonical_name) |
|
| `/api/member/merge` | POST | 合并:`{"source","target"}` → 将 source 并入 target 身份(统一 canonical_name) |
|
||||||
| `/api/ss/webhook` | POST | 接收 Surveillance Station Webhook(参数 `event_time`/`device_name`/`event_name`/`server_name`/`thumbnail_url`);映射后推送 Oracle `/api/ss/motion` |
|
| `/api/ss/webhook` | POST | **可选**:接收 Surveillance Station Webhook(参数 `event_time`/`device_name`/`event_name`/`server_name`/`thumbnail_url`),映射后推送 Oracle `/api/ss/motion`(SS 行動規則未配置则不触发) |
|
||||||
| `/api/ss/status` | GET | 运动监测状态:`poll_enabled` / `camera_loaded` / `camera_map` / `pushed_total` / `last_error` |
|
| `/api/ss/status` | GET | 运动监测状态:`poll_enabled` / `camera_loaded` / `camera_map` / `pushed_total` / `last_error` / 心跳 |
|
||||||
|
| `/api/ui/videos` | GET | 运动片段会话列表(`?date=&page=`;**只返回 `motion_` 前缀或有事件的会话**,0 段素材不展示) |
|
||||||
|
| `/api/ui/videos/<id>` | GET | 会话详情:`{video, events[]}`(事件含 person_list_json / person_appearances_json / is_attention_event) |
|
||||||
|
| `/api/ui/stats` | GET | 统计卡:videos / events / people / attention(`?date=` 过滤,口径与列表一致) |
|
||||||
|
| `/api/ui/people` | GET | 人物列表(按 canonical_name 聚合:display / labels / appearances / first_seen / features_json / display_uid) |
|
||||||
|
| `/api/ui/people/clips` | GET | 人物运动片段:`?label=&limit=` → 该人物出现过的运动片段(video_id / event_start_time / duration / summary / first_ts 缩略图定位 / clip_events) |
|
||||||
|
| `/api/ui/attention-events` | GET | 需关注事件(日期 + 涉及人物,已去重清洗) |
|
||||||
|
| `/api/ui/named-members` | GET | 已命名成员真名列表(AI 对话快捷选择) |
|
||||||
|
| `/api/ui/model-stats` | GET | 云端模型调用统计(按模型聚合 + 最近明细) |
|
||||||
|
| `/api/ui/service-status` | GET | NAS 同步状态 + Oracle 实时活动(代理,token 不下发浏览器) |
|
||||||
|
| `/api/proxy/frame` | GET | 事件帧图代理:`?video_id=&ts=&w=` → Oracle `/api/oracle/frame`(浏览器不直连 Oracle) |
|
||||||
|
| `/api/proxy/avatar` | GET | 人物头像代理:`?label=&w=` → Oracle `/api/oracle/avatar` |
|
||||||
|
|
||||||
> 已删除:`/api/core/callback/event`、`/media/<path>`(视频处理职责已迁移至 Oracle)。
|
> 已删除:`/api/core/callback/event`、`/media/<path>`(视频处理职责已迁移至 Oracle)。
|
||||||
|
|
||||||
### 5.3 云端结构化输出 JSON Schema
|
### 5.3 云端结构化输出 JSON Schema
|
||||||
|
|
||||||
整视频直传云端 VLM,模型直接产出结构化 JSON(`analyze_video` 返回):
|
运动片段直传云端 VLM,模型直接产出结构化 JSON(`analyze_video` 返回):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -317,15 +333,19 @@ chat_history 独立表(问答上下文摘要留存)
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Gemini 用 Files API 上传整视频后 `generateContent`;NVIDIA 用整视频 `video_url` + `num_frames=128`(模型内部自行采样帧),均不切片、不抽帧、不依赖 OpenCV
|
- Gemini 用 Files API 上传运动片段后 `generateContent`;NVIDIA 用整视频 `video_url` + `num_frames=128`(模型内部自行采样帧);均不依赖 OpenCV 人脸
|
||||||
- `person_service` 汇总全量 `people_mentioned` → LLM 合并为规范名 → 生成 `known_members_context` 回灌后续视频提示,使模型用真名指代
|
- `person_service` 汇总全量 `people_mentioned` → LLM 合并为规范名 → 生成 `known_members_context` 回灌后续片段提示,使模型用真名指代
|
||||||
- `action` / 描述由 AI 自由生成无枚举过滤,`is_attention_event` 由 AI 自行判断
|
- `action` / 描述由 AI 自由生成无枚举过滤,`is_attention_event` 由 AI 自行判断
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 关键技术
|
## 6. 关键技术
|
||||||
|
|
||||||
### 6.1 视频预处理(自适应关键帧)
|
### 6.1 视频预处理(历史记录:关键帧抽帧方案,v3 已废弃)
|
||||||
|
|
||||||
|
> ⚠️ 本小节为旧架构(v1/v2 本地抽帧分析)记录。**v3 运动事件驱动架构不再抽帧**:
|
||||||
|
> 整段素材按运动事件 ffmpeg 分割运动片段(`-c:v copy`),片段直传云端 VLM 分析。
|
||||||
|
> 保留此处仅作历史参考。
|
||||||
|
|
||||||
- **粗抽候选帧**:FFmpeg 快速 seek(逐帧 `ffmpeg -ss <ts> -frames:v 1`),替代 fps 滤镜全解码(30min 视频从 180s+ 降到 33s,6-8x 提速)
|
- **粗抽候选帧**:FFmpeg 快速 seek(逐帧 `ffmpeg -ss <ts> -frames:v 1`),替代 fps 滤镜全解码(30min 视频从 180s+ 降到 33s,6-8x 提速)
|
||||||
- **帧数自适应**:候选帧 `clamp(时长分钟×2, 30, 120)`;关键帧上限 `clamp(时长/150s, 8, 30)`(30min→12 帧,60min→24 帧,封顶 30)
|
- **帧数自适应**:候选帧 `clamp(时长分钟×2, 30, 120)`;关键帧上限 `clamp(时长/150s, 8, 30)`(30min→12 帧,60min→24 帧,封顶 30)
|
||||||
@@ -480,42 +500,59 @@ task_id=289(30s 测试片段)全链路打通:推送 5.7MB → Edge 分析
|
|||||||
|
|
||||||
| 组件 | 节点 | 路径 | 启动 |
|
| 组件 | 节点 | 路径 | 启动 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| FAM-Core | NAS | `/volume1/web/sentinel-home-ai/fam-core/` | `./venv/bin/gunicorn --chdir <路径> -w 1 -b 0.0.0.0:8000 --timeout 120 --daemon --pid /tmp/fam-core-gunicorn.pid src.fam_core.app:app` |
|
| FAM-Core | NAS | `/volume1/web/sentinel-home-ai/fam-core/` | `bash start_core.sh`(gunicorn -w 1 :8000,source .env 注入 DSM_*/ORACLE_SYNC_TOKEN) |
|
||||||
| FAM-UI | NAS | `/volume1/web/sentinel-home-ai/fam-ui/` | `./venv/bin/streamlit run src/app.py`(headless, :8501) |
|
| FAM-UI | NAS | `/volume1/web/sentinel-home-ai/fam-ui/dist/` | Vue3 构建产物,由 FAM-Core `static_app.py` 托管(无需独立进程);本地改代码后 `npm run build` 并 tar 部署 dist |
|
||||||
| FAM-Edge | Oracle | `/opt/fam-edge/` | `venv/bin/gunicorn -w 1 -b 0.0.0.0:5000 --timeout 1800 src.fam_edge.app:app`(日志 `/tmp/fam-edge.log`) |
|
| FAM-Edge | Oracle | `/opt/fam-edge/` | **systemd `fam-edge.service` 守护**(Restart=always);部署代码后 `sudo systemctl restart fam-edge`(勿手动 setsid,会端口冲突) |
|
||||||
| Ollama | Oracle | systemd 托管 | 环境变量 `OLLAMA_KEEP_ALIVE=-1` |
|
| Ollama | Oracle | systemd 托管 | 环境变量 `OLLAMA_KEEP_ALIVE=-1` |
|
||||||
|
|
||||||
|
> Oracle 部署方式:本地 git 提交 push Gitea → tar 管道到 `/opt/fam-edge`(`--strip-components=1` 解临时目录再 cp,避免动 data/venv/gdrive_videos)。
|
||||||
|
|
||||||
### 8.2 依赖
|
### 8.2 依赖
|
||||||
|
|
||||||
- **FAM-Core/UI(NAS, Python 3.10 venv)**:Flask, Gunicorn, **PyMySQL**(45KB 纯 Python 替代 19MB mysql-connector), PyYAML, requests;FAM-UI 另需 Streamlit + pandas
|
- **FAM-Core(NAS, Python 3.10 venv)**:Flask, Gunicorn, **PyMySQL**(45KB 纯 Python 替代 19MB mysql-connector), PyYAML, requests
|
||||||
- **FAM-Edge(Oracle, Python 3.8+ venv)**:Flask, Gunicorn, requests, PyYAML, opencv-python, numpy, **openai**(NVIDIA NIM 兼容 OpenAI API 规范,复用同一 SDK);Gemini 用 requests 直调 REST(不依赖 google-generativeai SDK)
|
- **FAM-UI(NAS, Node)**:Vue3 + Vite + Tailwind(`fam-ui/`,构建产物 dist 不入库);**不再依赖 Streamlit**
|
||||||
- **系统级**:FFmpeg(两端)、Ollama + qwen2.5:7b(Oracle)、MariaDB 10.11(NAS)
|
- **FAM-Edge(Oracle, Python venv)**:Flask, Gunicorn, requests, PyYAML, opencv-python, numpy, **openai**(NVIDIA NIM 兼容 OpenAI API 规范);Gemini 用 requests 直调 REST
|
||||||
|
- **系统级**:FFmpeg(两端;Oracle 端用于运动片段分割 + 帧图/头像)、Ollama + qwen2.5:7b(Oracle)、MariaDB 10.11(NAS)、rclone(Oracle,同步 Google Drive 素材)
|
||||||
|
|
||||||
### 8.3 配置文件要点
|
### 8.3 配置文件要点
|
||||||
|
|
||||||
**fam-core/config/config.yaml**(NAS,新架构 v2 —— 仅同步 + 问答):
|
**fam-core/config/config.yaml**(NAS —— 同步 + 问答 + 运动监测):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
server:
|
server:
|
||||||
port: 8000
|
port: 8000
|
||||||
database: # MariaDB(unix_socket 优先)
|
database: # MariaDB(unix_socket 优先)
|
||||||
unix_socket: "/run/mysqld/mysqld10.sock"
|
unix_socket: "/run/mysqld/mysqld10.sock"
|
||||||
oracle_sync: # 唯一后台线程配置
|
oracle_sync: # 增量镜像线程
|
||||||
base_url: "http://129.146.203.203:5000"
|
base_url: "http://129.146.203.203:5000"
|
||||||
token: "${ORACLE_SYNC_TOKEN}" # 与 Oracle 端 sync_api.token 一致
|
token: "${ORACLE_SYNC_TOKEN}" # 与 Oracle 端 sync_api.token 一致
|
||||||
interval_sec: 1800 # 每 30 分钟拉一次增量
|
interval_sec: 1800 # 每 30 分钟拉一次增量
|
||||||
timeout: 120
|
|
||||||
chat_handler:
|
chat_handler:
|
||||||
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask" # 问答统一走 Oracle 编排
|
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask"
|
||||||
timeout: 120
|
timeout: 120
|
||||||
|
motion_notifier: # 运动监测(轮询主路径)
|
||||||
|
enabled: true
|
||||||
|
poll_enabled: true
|
||||||
|
dsm_host: "192.168.50.64" # Surveillance Station
|
||||||
|
dsm_port: 5000
|
||||||
|
dsm_account: "${DSM_ACCOUNT}"
|
||||||
|
dsm_password: "${DSM_PASSWORD}"
|
||||||
|
camera_ids: [2]
|
||||||
|
camera_name_to_id: {"Generic_ONVIF-001": 2}
|
||||||
|
oracle_base_url: "http://129.146.203.203:5000"
|
||||||
|
oracle_token: "${ORACLE_SYNC_TOKEN}"
|
||||||
|
poll_interval_sec: 60
|
||||||
|
poll_window_hours: 2
|
||||||
|
batch_size: 100
|
||||||
|
heartbeat_interval_sec: 300 # 心跳(证明推送链路存活,< Oracle 侧 900s 阈值)
|
||||||
```
|
```
|
||||||
|
|
||||||
**fam-edge/config/config.yaml**(Oracle,整视频分析 + 同步 + 人物服务):
|
**fam-edge/config/config.yaml**(Oracle —— 素材分割 + 片段分析 + 同步 + 人物服务):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
server:
|
server:
|
||||||
port: 5000
|
port: 5000
|
||||||
gdrive_sync: # rclone 同步落地目录监听
|
gdrive_sync: # rclone 同步落地目录(整段素材)
|
||||||
enabled: true
|
enabled: true
|
||||||
local_dir: "/opt/fam-edge/gdrive_videos"
|
local_dir: "/opt/fam-edge/gdrive_videos"
|
||||||
watch_interval_sec: 30
|
watch_interval_sec: 30
|
||||||
@@ -524,7 +561,7 @@ gdrive_sync: # rclone 同步落地目录监听
|
|||||||
oracle_db:
|
oracle_db:
|
||||||
path: "/opt/fam-edge/data/oracle.db"
|
path: "/opt/fam-edge/data/oracle.db"
|
||||||
sync_api:
|
sync_api:
|
||||||
token: "${ORACLE_SYNC_TOKEN}" # NAS 拉取鉴权(与 NAS oracle_sync.token 一致)
|
token: "${ORACLE_SYNC_TOKEN}"
|
||||||
person_service:
|
person_service:
|
||||||
schedule_interval_sec: 1800 # 每 30 分钟重新汇总人物
|
schedule_interval_sec: 1800 # 每 30 分钟重新汇总人物
|
||||||
model: "gemini"
|
model: "gemini"
|
||||||
@@ -532,15 +569,21 @@ video_processing:
|
|||||||
max_concurrent: 1 # 单视频串行,避免抢占云端配额
|
max_concurrent: 1 # 单视频串行,避免抢占云端配额
|
||||||
timeout: 900
|
timeout: 900
|
||||||
vision_order: ["gemini", "nvidia"]
|
vision_order: ["gemini", "nvidia"]
|
||||||
|
motion_segment: # 运动片段分割(运动事件驱动架构)
|
||||||
|
clips_dir: "/opt/fam-edge/motion_clips"
|
||||||
|
keep_audio: true # 保留音频(pcm_alaw -> aac 64k 转码)
|
||||||
|
min_duration_sec: 1
|
||||||
|
unfinished_grace_sec: 10 # start+duration 距当前 ≤10s 视为已结束
|
||||||
models:
|
models:
|
||||||
- provider: "gemini"
|
- provider: "gemini"
|
||||||
role: "vision"
|
role: "vision"
|
||||||
model_name: "gemini-flash-latest"
|
model_name: "gemini-flash-latest"
|
||||||
api_key: "${GEMINI_API_KEY}"
|
api_key: "${GEMINI_API_KEY}"
|
||||||
|
extra_api_keys: ["${GEMINI_API_KEY_2}", "${GEMINI_API_KEY_3}", "${GEMINI_API_KEY_4}"]
|
||||||
timeout: 600
|
timeout: 600
|
||||||
- provider: "nvidia"
|
- provider: "nvidia"
|
||||||
role: "vision"
|
role: "vision"
|
||||||
model_name: "nvidia/nemotron-nano-12b-v2-vl" # 整视频 video_url 输入(内部采样帧)
|
model_name: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" # 整视频 video_url 输入(内部采样帧)
|
||||||
base_url: "https://integrate.api.nvidia.com/v1"
|
base_url: "https://integrate.api.nvidia.com/v1"
|
||||||
api_key: "${NVIDIA_API_KEY}"
|
api_key: "${NVIDIA_API_KEY}"
|
||||||
timeout: 600
|
timeout: 600
|
||||||
@@ -597,11 +640,14 @@ print('NVIDIA NIM 连接成功:', response.choices[0].message.content)
|
|||||||
|
|
||||||
### 8.4 运维注意事项
|
### 8.4 运维注意事项
|
||||||
|
|
||||||
- NAS 部署目录**不是 git 仓库**(文件拷贝部署),同步代码用 stdin 管道:`ssh ... "cat > 远端路径" < 本地文件`
|
- NAS 部署目录**不是 git 仓库**(文件拷贝部署),同步代码用 stdin 管道:`tar czf - <子目录> | ssh ... 'tar xzf - -C <目标>'`
|
||||||
- NAS scp 子系统被禁用,同样用 stdin 管道传文件
|
- NAS scp 子系统被禁用,同样用 stdin 管道传文件
|
||||||
- 远端 kill gunicorn 时 pkill/pgrep 会匹配 SSH 自身命令行导致断连,用 `pgrep -f 'gunicorn -w [1]'` 字符类技巧或 PID 文件
|
- **Oracle fam-edge 由 systemd `fam-edge.service` 守护(Restart=always)**:部署代码后必须 `sudo systemctl restart fam-edge`;手动 `setsid` 启动会和守护打架(端口 `Connection in use`)
|
||||||
- fam-core 启动模块路径是 `src.fam_core.app:app`(不是 `fam_core.app:app`)
|
- NAS 远端 kill gunicorn 用 `ps aux | grep "[f]am-core/venv/bin/gunicorn"` 字符类技巧(pkill/pgrep 会匹配 SSH 自身命令行导致断连)
|
||||||
|
- fam-core 启动模块路径是 `src.fam_core.app:app`(不是 `fam_core.app:app`);`start_core.sh` 会 source 仓库根 `.env` 注入 `DSM_*/ORACLE_SYNC_TOKEN`
|
||||||
- Edge 单 worker 处理任务期间 `/health` 可能不响应,属正常
|
- Edge 单 worker 处理任务期间 `/health` 可能不响应,属正常
|
||||||
|
- **运动数据清理**:切换架构/重新提取时清 Oracle `videos/events/people` + `motion_clips/`(保留 `ss_motion_events` 与素材)与 NAS `sync_*` 镜像,重启两端自动重新分割分析
|
||||||
|
- **前端构建产物 `fam-ui/dist` 不入库**(.gitignore),改前端后需 `npm run build` 并单独 tar 部署 dist
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -611,17 +657,17 @@ print('NVIDIA NIM 连接成功:', response.choices[0].message.content)
|
|||||||
# 1. 初始化数据库(NAS)
|
# 1. 初始化数据库(NAS)
|
||||||
python scripts/init_db.py
|
python scripts/init_db.py
|
||||||
|
|
||||||
# 2. 启动 FAM-Core(NAS)
|
# 2. 启动 FAM-Core(NAS,含运动监测轮询 + Oracle-Sync)
|
||||||
cd fam-core && gunicorn -w 1 -b 0.0.0.0:8000 --timeout 120 src.fam_core.app:app
|
cd /volume1/web/sentinel-home-ai && bash start_core.sh
|
||||||
|
|
||||||
# 3. 启动 FAM-Edge(Oracle)
|
# 3. 启动 FAM-Edge(Oracle,systemd 守护)
|
||||||
cd fam-edge && gunicorn -w 1 -b 0.0.0.0:5000 --timeout 1800 src.fam_edge.app:app
|
sudo systemctl restart fam-edge
|
||||||
|
|
||||||
# 4. 启动 FAM-UI(NAS)
|
# 4. 前端(Vue3,构建后由 FAM-Core 托管,无需独立进程)
|
||||||
cd fam-ui && streamlit run src/app.py
|
cd fam-ui && npm run build # 产物 fam-ui/dist/
|
||||||
|
|
||||||
# 或使用脚本
|
# 或使用脚本
|
||||||
./scripts/start_core.sh && ./scripts/start_edge.sh && ./scripts/start_ui.sh
|
./scripts/start_core.sh && ./scripts/start_edge.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -724,22 +770,14 @@ export NVIDIA_API_KEY="nvapi-9cFAdO5xdbwPuxS8KGRTnlVimn1gJzbbbzWNhPwHa_Yl3pTe-Pf
|
|||||||
|
|
||||||
## 12. 当前进度与 v1.1 计划
|
## 12. 当前进度与 v1.1 计划
|
||||||
|
|
||||||
### 已完成(截至 2026-08-20)
|
### 已完成(截至 2026-08-22,v3 运动事件驱动架构)
|
||||||
|
|
||||||
- 全部模块代码 + DDL + 部署脚本;NAS/Oracle 双端部署运行
|
- **运动事件驱动**(v3,2026-08-22):不再分析整段视频。NAS MotionNotifier 轮询 SS 事件列表(60s,游标续用/失败重试/心跳)推送 `ss_motion_events`;Oracle 整段素材按运动事件 ffmpeg 分割运动片段(`-c:v copy -c:a aac`,只分割已结束事件,`motion_event_id` 幂等),**只分析运动片段**;前端契约不变
|
||||||
- 模型基准测试(原 llava-phi3 预热 4.3s PASS,**已替换为 qwen2.5:7b**);Ollama 常驻内存
|
- **数据迁移**(2026-08-22):整段提取的旧数据(Oracle videos/events/people + NAS 镜像)已清空并按运动视频重新提取;历史素材(NAS 轮询启动前)无运动事件,时间轴已过滤其空会话
|
||||||
- 架构改为推送模式(NAS 上传整段视频 → Edge 同步分析 → 结果随响应返回)
|
- **人物管理重设计**(2026-08-22):人物卡新增「运动片段」区块(缩略图/时间/摘要/事件数,点击跳时间轴定位);新增 `GET /api/ui/people/clips`;Timeline 支持 `?video=` 定位
|
||||||
- 关键帧自适应帧数 + FFmpeg 快速 seek(6-8x 提速)
|
- 前端从 Streamlit 迁移为 **Vue3 + Vite + Tailwind SPA**(由 FAM-Core 托管 dist)
|
||||||
- FAM-Core API 全端点测试通过
|
- 运动事件链路(NAS→Oracle 单向推送)+ 心跳 fail-open;Gemini 多 Key 轮换 + 模型调用统计
|
||||||
- FAM-UI 部署(Streamlit 1.61.1)
|
- 双端部署:NAS `start_core.sh`(source .env);Oracle **systemd `fam-edge.service` 守护**
|
||||||
- 真实视频性能基准(30min/360MB → 929s)
|
|
||||||
- **E2E 全链路打通**:task 289 → SUCCESS,monitor_events/event_details 落库正确
|
|
||||||
- 可靠性加固:僵尸任务回收、文件日志、datetime 空值兜底、超时按实测调整
|
|
||||||
- **架构重构(移除本地融合,云端直出直存)**:
|
|
||||||
- 视频分析链路:云端 VLM(Gemini `gemini-flash-latest` / NVIDIA NIM `llama-3.2-11b-vision-instruct`)直出结构化 JSON → Edge `format_cloud_result` 格式化/校验(无模型调用)→ 直存 NAS DB;本地 Ollama 不再参与视频摘要与融合(`run_text_fusion` 已移除)
|
|
||||||
- 智能问答链路:新增 `chat()` 方法,`run_qa` 按 Gemini → NVIDIA → 本地 Ollama 降级编排;端点 `/api/edge/chat/ask`;NAS Chat-Handler 经 Edge 编排(`qa_url`),不再直连 Ollama
|
|
||||||
- 生产目录已切回 `/volume1/surveillance/Generic_ONVIF-001`,285 历史视频 forward-only 占位跳过
|
|
||||||
- **双端部署验证通过**(2026-08-20):Oracle Edge 7 文件部署 + 重启,`/api/edge/chat/ask` 实测 provider=nvidia(Gemini 超时→NVIDIA 兜底成功);NAS chat_handler + config 手术式更新 + HUP 重载,插入临时上下文实测 NAS→Edge 编排链路 43s 返回并落库(测试数据已清理);Edge 日志确认 task 294(360MB)以新架构处理中、三模型健康检查全通过
|
|
||||||
|
|
||||||
详细进度见 `PROGRESS.md`。
|
详细进度见 `PROGRESS.md`。
|
||||||
|
|
||||||
@@ -750,6 +788,7 @@ export NVIDIA_API_KEY="nvapi-9cFAdO5xdbwPuxS8KGRTnlVimn1gJzbbbzWNhPwHa_Yl3pTe-Pf
|
|||||||
| 1 | 单元测试(JSON parser / circuit breaker / schema 校验) | 中 |
|
| 1 | 单元测试(JSON parser / circuit breaker / schema 校验) | 中 |
|
||||||
| 2 | Tailscale 修复(NAS userspace 模式升级,流量不走公网) | 低 |
|
| 2 | Tailscale 修复(NAS userspace 模式升级,流量不走公网) | 低 |
|
||||||
| 3 | daily_summaries 每日摘要 | 低 |
|
| 3 | daily_summaries 每日摘要 | 低 |
|
||||||
|
| 4 | 运动片段音频策略验证(当前保留 aac 转码) | 低 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
154
docs/DEPLOY.md
154
docs/DEPLOY.md
@@ -1,11 +1,10 @@
|
|||||||
# 部署指南
|
# 部署指南(v3 运动事件驱动架构,2026-08-22 同步)
|
||||||
|
|
||||||
## 1. NAS 端部署 (FAM-Core + FAM-UI + MariaDB)
|
## 1. NAS 端部署 (FAM-Core + FAM-UI + MariaDB)
|
||||||
|
|
||||||
### 1.1 MariaDB
|
### 1.1 MariaDB
|
||||||
```bash
|
```bash
|
||||||
# 安装 MariaDB (通过 Synology 套件中心)
|
# 通过 Synology 套件中心安装 MariaDB(10.11),unix_socket=/run/mysqld/mysqld10.sock
|
||||||
# 确认端口 3306 可用
|
|
||||||
|
|
||||||
# 执行 DDL
|
# 执行 DDL
|
||||||
python3 scripts/init_db.py --host 127.0.0.1 --port 3306 --user root --password <密码>
|
python3 scripts/init_db.py --host 127.0.0.1 --port 3306 --user root --password <密码>
|
||||||
@@ -13,143 +12,80 @@ python3 scripts/init_db.py --host 127.0.0.1 --port 3306 --user root --password <
|
|||||||
|
|
||||||
### 1.2 FAM-Core
|
### 1.2 FAM-Core
|
||||||
```bash
|
```bash
|
||||||
cd /path/to/sentinel-home-ai/fam-core
|
cd /volume1/web/sentinel-home-ai/fam-core
|
||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# 配置
|
# 配置
|
||||||
cp config/config.yaml.example config/config.yaml
|
cp config/config.yaml.example config/config.yaml
|
||||||
# 编辑 config.yaml: 数据库密码、Oracle Tailscale IP、token 等
|
# 编辑 config.yaml:数据库密码、oracle_sync.base_url/token、motion_notifier(DSM 凭据走 .env)
|
||||||
|
|
||||||
# 启动
|
# 仓库根 .env 提供 DSM_ACCOUNT/DSM_PASSWORD/ORACLE_SYNC_TOKEN(start_core.sh 会 source)
|
||||||
./scripts/start_core.sh
|
|
||||||
# 或手动: gunicorn -w 1 -b 0.0.0.0:8000 --timeout 120 src.fam_core.app:app
|
# 启动(含 Oracle-Sync + MotionNotifier 轮询)
|
||||||
|
cd /volume1/web/sentinel-home-ai && bash start_core.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.3 FAM-UI
|
### 1.3 FAM-UI(Vue3 SPA,无独立进程)
|
||||||
```bash
|
```bash
|
||||||
cd /path/to/sentinel-home-ai/fam-ui
|
cd /Users/ericwyuan/Desktop/Work/sentinel-home-ai/fam-ui # 本地开发机
|
||||||
python3 -m venv venv
|
npm install
|
||||||
source venv/bin/activate
|
npm run build # 产物 fam-ui/dist/
|
||||||
pip install -r requirements.txt
|
# 部署 dist 到 NAS(构建产物不入库):
|
||||||
|
tar czf - fam-ui/dist | ssh -p 2222 ericwyuan@192.168.50.64 \
|
||||||
# 配置
|
'cd /volume1/web/sentinel-home-ai && rm -rf fam-ui/dist && tar xzf -'
|
||||||
cp config/config.yaml.example config/config.yaml
|
# 浏览器访问 http://192.168.50.64:8000(由 FAM-Core static_app.py 托管,Vue Router history 回退)
|
||||||
# 编辑 config.yaml: 数据库连接、FAM-Core 地址
|
|
||||||
|
|
||||||
# 启动
|
|
||||||
./scripts/start_ui.sh
|
|
||||||
# 或手动: streamlit run src/app.py --server.port 8501 --server.address 0.0.0.0
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Oracle 端部署 (FAM-Edge + Ollama + FFmpeg)
|
## 2. Oracle 端部署 (FAM-Edge + Ollama + FFmpeg)
|
||||||
|
|
||||||
### 2.1 系统依赖
|
### 2.1 系统依赖
|
||||||
```bash
|
```bash
|
||||||
# FFmpeg
|
sudo apt update && sudo apt install -y ffmpeg python3-opencv
|
||||||
sudo apt update && sudo apt install -y ffmpeg
|
|
||||||
|
|
||||||
# OpenCV 依赖
|
# Ollama + qwen2.5:7b(纯文本,仅问答兜底)
|
||||||
sudo apt install -y python3-opencv libopencv-dev
|
|
||||||
|
|
||||||
# Ollama
|
|
||||||
curl -fsSL https://ollama.com/install.sh | sh
|
curl -fsSL https://ollama.com/install.sh | sh
|
||||||
systemctl enable ollama
|
systemctl enable ollama && systemctl start ollama
|
||||||
systemctl start ollama
|
ollama pull qwen2.5:7b
|
||||||
|
|
||||||
# 拉取模型
|
|
||||||
ollama pull llava-phi3
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 FAM-Edge
|
### 2.2 FAM-Edge(systemd 守护)
|
||||||
```bash
|
```bash
|
||||||
cd /path/to/sentinel-home-ai/fam-edge
|
cd /opt/fam-edge
|
||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# 配置
|
# 配置:config/config.yaml(素材目录/DB/模型/motion_segment);.env 提供 ORACLE_SYNC_TOKEN/GEMINI_API_KEY*/NVIDIA_API_KEY
|
||||||
cp config/config.yaml.example config/config.yaml
|
|
||||||
# 编辑 config.yaml: NAS Tailscale IP、media_token、Gemini API Key
|
|
||||||
|
|
||||||
# 设置 Gemini API Key 环境变量
|
|
||||||
export GEMINI_API_KEY="your-api-key-here"
|
|
||||||
|
|
||||||
# 启动
|
|
||||||
./scripts/start_edge.sh
|
|
||||||
# 或手动: gunicorn -w 1 -b 0.0.0.0:5000 --timeout 600 src.fam_edge.app:app
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 模型可行性压测 (阶段一验证)
|
|
||||||
```bash
|
|
||||||
# 测试 llava-phi3 单张图推理耗时
|
|
||||||
ollama run llava-phi3 "描述这张图片" --images /path/to/test.jpg
|
|
||||||
|
|
||||||
# 预期:
|
|
||||||
# - 单张 ≤ 8 秒 → 可行
|
|
||||||
# - 单张 ≤ 30 秒 → 可接受
|
|
||||||
# - 单张 > 30 秒 → 需换更小模型或减帧
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Tailscale 组网
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# NAS 端
|
|
||||||
curl -fsSL https://tailscale.com/install.sh | sh
|
|
||||||
sudo tailscale up
|
|
||||||
|
|
||||||
# Oracle 端
|
|
||||||
curl -fsSL https://tailscale.com/install.sh | sh
|
|
||||||
sudo tailscale up
|
|
||||||
|
|
||||||
# 验证
|
|
||||||
# NAS: ping 100.x.x.20 (Oracle Tailscale IP)
|
|
||||||
# Oracle: ping 100.x.x.10 (NAS Tailscale IP)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. 开机自启 (可选)
|
|
||||||
|
|
||||||
### NAS 端 (使用任务计划)
|
|
||||||
```bash
|
|
||||||
# Synology DSM -> 控制面板 -> 任务计划 -> 新增 -> 触发的任务 -> 开机
|
|
||||||
# 脚本:
|
|
||||||
cd /path/to/sentinel-home-ai
|
|
||||||
./scripts/start_core.sh &
|
|
||||||
./scripts/start_ui.sh &
|
|
||||||
```
|
|
||||||
|
|
||||||
### Oracle 端 (使用 systemd)
|
|
||||||
```bash
|
|
||||||
sudo tee /etc/systemd/system/fam-edge.service << 'EOF'
|
|
||||||
[Unit]
|
|
||||||
Description=FAM-Edge Service
|
|
||||||
After=network.target ollama.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=ubuntu
|
|
||||||
WorkingDirectory=/path/to/sentinel-home-ai/fam-edge
|
|
||||||
Environment=GEMINI_API_KEY=your-api-key
|
|
||||||
ExecStart=/path/to/sentinel-home-ai/fam-edge/venv/bin/gunicorn -w 1 -b 0.0.0.0:5000 --timeout 600 src.fam_edge.app:app
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
|
# systemd 服务(已配置 /etc/systemd/system/fam-edge.service,Restart=always)
|
||||||
sudo systemctl enable fam-edge
|
sudo systemctl enable fam-edge
|
||||||
sudo systemctl start fam-edge
|
sudo systemctl restart fam-edge # 部署代码后必须用 systemctl 重启,勿手动 setsid
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. 验证清单
|
## 3. 代码同步(tar 管道,scp 在 NAS 被禁用)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# NAS(fam-core):仓库根即部署根,直接解包
|
||||||
|
tar czf - --exclude=venv --exclude=__pycache__ fam-core | \
|
||||||
|
ssh -p 2222 ericwyuan@192.168.50.64 'tar xzf - -C /volume1/web/sentinel-home-ai'
|
||||||
|
|
||||||
|
# Oracle(fam-edge):/opt/fam-edge 是 fam-edge 根,--strip-components=1 解临时目录再 cp
|
||||||
|
tar czf - --exclude=venv --exclude=__pycache__ --exclude=data --exclude=gdrive_videos fam-edge | \
|
||||||
|
ssh ubuntu@129.146.203.203 'mkdir -p /opt/fam-edge/tmp_d && tar xzf - --strip-components=1 -C /opt/fam-edge/tmp_d && \
|
||||||
|
cp -rf /opt/fam-edge/tmp_d/* /opt/fam-edge/ && rm -rf /opt/fam-edge/tmp_d && sudo systemctl restart fam-edge'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 验证清单
|
||||||
|
|
||||||
| 项目 | 命令 | 预期 |
|
| 项目 | 命令 | 预期 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| NAS MariaDB | `mysql -u root -p -e "SHOW DATABASES"` | 包含 sentinel_home_ai |
|
| NAS MariaDB | `mysql -u root -p -e "SHOW DATABASES"` | 包含 sentinel_home_ai |
|
||||||
| NAS FAM-Core | `curl http://localhost:8000/health` | `{"status":"ok"}` |
|
| NAS FAM-Core | `curl http://localhost:8000/health` | `{"status":"ok"}` |
|
||||||
| NAS FAM-UI | 浏览器访问 `http://192.168.50.64:8501` | Streamlit 页面 |
|
| NAS 运动监测 | `curl http://localhost:8000/api/ss/status` | `poll_enabled: true, running: true` |
|
||||||
| Oracle FAM-Edge | `curl http://localhost:5000/health` | `{"status":"ok"}` |
|
| NAS FAM-UI | 浏览器访问 `http://192.168.50.64:8000` | Vue3 SPA(时间轴/人物管理/统计) |
|
||||||
| Oracle Ollama | `curl http://localhost:11434/api/tags` | 模型列表含 llava-phi3 |
|
| Oracle FAM-Edge | `curl http://localhost:5000/health` | `{"status":"ok","queue_alive":true}` |
|
||||||
| Tailscale | NAS `ping 100.x.x.20` | 通 |
|
| Oracle 运动事件 | `sqlite3 /opt/fam-edge/data/oracle.db "SELECT COUNT(*) FROM ss_motion_events"` | >0(NAS 推送) |
|
||||||
|
| Oracle 运动片段 | `ls /opt/fam-edge/motion_clips/` | 存在 motion_*.mp4(素材分割产物) |
|
||||||
|
| Oracle Ollama | `curl http://localhost:11434/api/tags` | 模型列表含 qwen2.5:7b |
|
||||||
|
|||||||
179
fam-core/tests/test_motion_notifier.py
Normal file
179
fam-core/tests/test_motion_notifier.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from fam_core.motion_notifier.motion_notifier import MotionNotifier
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(**overrides):
|
||||||
|
base = {
|
||||||
|
"enabled": True,
|
||||||
|
"poll_enabled": False,
|
||||||
|
"dsm_host": "192.168.50.64",
|
||||||
|
"dsm_port": 5000,
|
||||||
|
"dsm_account": "ericwyuan",
|
||||||
|
"dsm_password": "secret",
|
||||||
|
"camera_name_to_id": {"Generic_ONVIF-001": 2},
|
||||||
|
"oracle_base_url": "http://oracle.example:5000",
|
||||||
|
"oracle_token": "tok123",
|
||||||
|
"heartbeat_interval_sec": 300,
|
||||||
|
"timeout_sec": 5,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return {"motion_notifier": base}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_notifier(monkeypatch, **cfg_overrides):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"fam_core.motion_notifier.motion_notifier.load_config",
|
||||||
|
lambda: _cfg(**cfg_overrides))
|
||||||
|
return MotionNotifier()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
def __init__(self, status_code=200, payload=None, text=""):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._payload = payload or {}
|
||||||
|
self.text = text
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# build_event_from_webhook / 时间解析 / 摄像头映射
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_build_event_from_webhook_maps_known_camera(monkeypatch):
|
||||||
|
n = _make_notifier(monkeypatch)
|
||||||
|
ev = n.build_event_from_webhook({
|
||||||
|
"device_name": "Generic_ONVIF-001",
|
||||||
|
"event_time": "2026-08-22T10:35:00",
|
||||||
|
"thumbnail_url": "abc,123",
|
||||||
|
})
|
||||||
|
assert ev is not None
|
||||||
|
assert ev["camera_id"] == 2
|
||||||
|
assert ev["event_type"] == 10
|
||||||
|
assert ev["duration"] == 0
|
||||||
|
assert isinstance(ev["start_time"], int)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_event_from_webhook_missing_fields_returns_none(monkeypatch):
|
||||||
|
n = _make_notifier(monkeypatch)
|
||||||
|
assert n.build_event_from_webhook({"device_name": "Generic_ONVIF-001"}) is None
|
||||||
|
assert n.build_event_from_webhook({"event_time": "2026-08-22T10:35:00"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_event_from_webhook_unmapped_camera_gives_none_id(monkeypatch):
|
||||||
|
n = _make_notifier(monkeypatch)
|
||||||
|
ev = n.build_event_from_webhook({
|
||||||
|
"device_name": "某个从没见过的摄像头",
|
||||||
|
"event_time": "2026-08-22T10:35:00",
|
||||||
|
})
|
||||||
|
assert ev is not None
|
||||||
|
assert ev["camera_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_synth_event_id_stable_for_same_input(monkeypatch):
|
||||||
|
n = _make_notifier(monkeypatch)
|
||||||
|
a = n._synth_event_id("cam1", "2026-08-22T10:00:00", "thumb1")
|
||||||
|
b = n._synth_event_id("cam1", "2026-08-22T10:00:00", "thumb1")
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_synth_event_id_differs_for_different_thumbnail(monkeypatch):
|
||||||
|
"""核心诉求: 同一分钟内的多次真实事件要能区分开,不能因为哈希碰撞被去重掉。"""
|
||||||
|
n = _make_notifier(monkeypatch)
|
||||||
|
a = n._synth_event_id("cam1", "2026-08-22T10:00:00", "thumb1")
|
||||||
|
b = n._synth_event_id("cam1", "2026-08-22T10:00:00", "thumb2")
|
||||||
|
assert a != b
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_ss_time_to_epoch_iso_format(monkeypatch):
|
||||||
|
n = _make_notifier(monkeypatch)
|
||||||
|
assert n.parse_ss_time_to_epoch("2026-08-22T10:35:00") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_ss_time_to_epoch_invalid_returns_none(monkeypatch):
|
||||||
|
n = _make_notifier(monkeypatch)
|
||||||
|
assert n.parse_ss_time_to_epoch("not a time") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_ss_time_to_epoch_empty_returns_none(monkeypatch):
|
||||||
|
n = _make_notifier(monkeypatch)
|
||||||
|
assert n.parse_ss_time_to_epoch("") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 心跳(跟轮询无关,enabled=true 就该跑)
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_send_heartbeat_success_updates_state(monkeypatch):
|
||||||
|
n = _make_notifier(monkeypatch)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_post(url, json=None, timeout=None):
|
||||||
|
calls.append((url, json))
|
||||||
|
return _FakeResp(200, {"status": "ok", "received": 0, "stored": 0})
|
||||||
|
monkeypatch.setattr("fam_core.motion_notifier.motion_notifier.requests.post", fake_post)
|
||||||
|
ok = n.send_heartbeat()
|
||||||
|
assert ok is True
|
||||||
|
assert n._last_heartbeat_at is not None
|
||||||
|
assert n._last_heartbeat_error is None
|
||||||
|
assert calls[0][0] == "http://oracle.example:5000/api/ss/motion"
|
||||||
|
assert calls[0][1]["events"] == []
|
||||||
|
assert calls[0][1]["token"] == "tok123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_heartbeat_http_error_records_failure(monkeypatch):
|
||||||
|
n = _make_notifier(monkeypatch)
|
||||||
|
|
||||||
|
def fake_post(url, json=None, timeout=None):
|
||||||
|
return _FakeResp(500, {}, text="boom")
|
||||||
|
monkeypatch.setattr("fam_core.motion_notifier.motion_notifier.requests.post", fake_post)
|
||||||
|
ok = n.send_heartbeat()
|
||||||
|
assert ok is False
|
||||||
|
assert n._last_heartbeat_error == "HTTP 500"
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_heartbeat_network_exception_records_failure(monkeypatch):
|
||||||
|
import requests as _requests
|
||||||
|
n = _make_notifier(monkeypatch)
|
||||||
|
|
||||||
|
def fake_post(url, json=None, timeout=None):
|
||||||
|
raise _requests.RequestException("connection refused")
|
||||||
|
monkeypatch.setattr("fam_core.motion_notifier.motion_notifier.requests.post", fake_post)
|
||||||
|
ok = n.send_heartbeat()
|
||||||
|
assert ok is False
|
||||||
|
assert "connection refused" in n._last_heartbeat_error
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_runs_heartbeat_thread_even_when_poll_disabled(monkeypatch):
|
||||||
|
"""核心诉求: 心跳跟"是否轮询 SS"是两回事——poll_enabled=false 时轮询线程
|
||||||
|
不应该启动,但心跳线程必须照样跑,否则甲骨文永远收不到心跳,
|
||||||
|
has_motion_in_range_local() 会一直 fail-open,省配额的效果就没了。"""
|
||||||
|
n = _make_notifier(monkeypatch, poll_enabled=False, heartbeat_interval_sec=3600)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"fam_core.motion_notifier.motion_notifier.requests.post",
|
||||||
|
lambda url, json=None, timeout=None: _FakeResp(200, {"stored": 0}))
|
||||||
|
try:
|
||||||
|
n.start()
|
||||||
|
time.sleep(0.2)
|
||||||
|
assert n.is_alive() is False # 轮询线程未启动
|
||||||
|
assert n._hb_thread is not None and n._hb_thread.is_alive()
|
||||||
|
finally:
|
||||||
|
n.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_does_nothing_when_disabled(monkeypatch):
|
||||||
|
n = _make_notifier(monkeypatch, enabled=False)
|
||||||
|
n.start()
|
||||||
|
time.sleep(0.1)
|
||||||
|
assert n._hb_thread is None
|
||||||
|
assert n.is_alive() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_includes_heartbeat_fields(monkeypatch):
|
||||||
|
n = _make_notifier(monkeypatch)
|
||||||
|
st = n.status()
|
||||||
|
assert "heartbeat_running" in st
|
||||||
|
assert "last_heartbeat_at" in st
|
||||||
|
assert "last_heartbeat_error" in st
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# FAM-Edge 启动脚本 (Oracle 端)
|
# FAM-Edge 启动脚本 (Oracle 端) —— 仅本地调试用
|
||||||
|
# 生产环境由 systemd fam-edge.service 守护(Restart=always,/opt/fam-edge/venv/bin/gunicorn
|
||||||
|
# -w 1 --threads 4 --timeout 1800);部署后请用 `sudo systemctl restart fam-edge`。
|
||||||
# 用法: ./scripts/start_edge.sh
|
# 用法: ./scripts/start_edge.sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# FAM-UI 启动脚本 (NAS 端)
|
|
||||||
# 用法: ./scripts/start_ui.sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)/fam-ui"
|
|
||||||
CONFIG_FILE="$APP_DIR/config/config.yaml"
|
|
||||||
|
|
||||||
if [ ! -f "$CONFIG_FILE" ]; then
|
|
||||||
echo "错误: 配置文件不存在: $CONFIG_FILE"
|
|
||||||
echo "请复制 config.yaml.example 为 config.yaml 并修改实际值"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$APP_DIR"
|
|
||||||
|
|
||||||
# 注入项目级 .env(ORACLE_SYNC_TOKEN 等),供 config.yaml ${VAR} 解析
|
|
||||||
if [ -f "$(dirname "$APP_DIR")/.env" ]; then
|
|
||||||
source "$(dirname "$APP_DIR")/.env"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 检查虚拟环境
|
|
||||||
if [ -d "venv" ]; then
|
|
||||||
source venv/bin/activate
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "启动 FAM-UI Streamlit (端口 8501)..."
|
|
||||||
exec streamlit run src/app.py --server.port 8501 --server.address 0.0.0.0
|
|
||||||
Reference in New Issue
Block a user