Compare commits
2 Commits
3cb08c9749
...
cf3d145c57
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf3d145c57 | ||
|
|
7948f55b02 |
16
.workbuddy/memory/2026-08-25.md
Normal file
16
.workbuddy/memory/2026-08-25.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# 2026-08-25 工作日志
|
||||||
|
|
||||||
|
## 前端迁移收尾 + 登录故障修复
|
||||||
|
- **前端最终落点**:云服务器 129.146.26.249 的 Caddy :80(`http://129.146.26.249/`),
|
||||||
|
SPA 静态在 `/var/www/fam-ui`,`/api/*` 经 frp 隧道反代回 NAS fam-core :8000。
|
||||||
|
**注意:新服务器 :8000 是 frp 隧道口(直通 NAS API),不是前端入口**,登录请用 :80。
|
||||||
|
- **登录失败根因**:重启 fam-core 时 FAM_AUTH_USER/FAM_AUTH_PASS 未固化进 .env →
|
||||||
|
auth.py fail-closed(未配置即拒绝所有登录)→ 全部 401。已补进
|
||||||
|
`/volume1/web/sentinel-home-ai/.env`(5 键:ORACLE_SYNC_TOKEN/DSM_ACCOUNT/DSM_PASSWORD/
|
||||||
|
FAM_AUTH_USER/FAM_AUTH_PASS=ericwyuan/iLoveJava5)并重启,登录恢复 200。
|
||||||
|
- **旧 IP 清理**:本地仓库 config.yaml(4 处)+ chat_handler/motion_notifier/oracle_sync
|
||||||
|
兜底默认值 + docs/DEPLOY.md + PROGRESS.md 共 6 文件旧 IP 129.146.203.203 → 129.146.26.249,
|
||||||
|
已清 __pycache__。仓库/部署/README 三处一致。**未提交**。
|
||||||
|
- 孤儿云库 sentinel_home_ai + famcore 用户已 DROP(新服务器)。
|
||||||
|
- SS Webhook:代码中为可选补充(motion_bp.py 文档明确),SS 未配行动规则故休眠,
|
||||||
|
是否删除待用户拍板。
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
- **不再分析整段视频**。Google Drive --rclone--> Oracle gdrive_videos(整段素材保留);
|
- **不再分析整段视频**。Google Drive --rclone--> Oracle gdrive_videos(整段素材保留);
|
||||||
Oracle 按 NAS 推送的 `ss_motion_events`(start_time/duration,Unix epoch)ffmpeg 分割运动片段
|
Oracle 按 NAS 推送的 `ss_motion_events`(start_time/duration,Unix epoch)ffmpeg 分割运动片段
|
||||||
(`-c:v copy -c:a aac` 保留音频,产物 `/opt/fam-edge/motion_clips/`),**只分析运动片段**。
|
(`-c:v copy -c:a aac` 保留音频,产物 `/opt/fam-edge/motion_clips/`),**只分析运动片段**。
|
||||||
|
- **Drive 中转是跨境提速设计(非仅 NAT 原因)**:中国→海外裸 HTTP/SCP 直传 Oracle 很难(跨境单 TCP 流
|
||||||
|
丢包掉速);改走 Google(CloudSync 分块并发 + 断点续传,抗抖动)→ Oracle rclone 每 5min 拉取
|
||||||
|
(海外数据中心互联,Gbps)。**不要轻易提议砍掉 Drive 改 NAS 直连 Oracle**。
|
||||||
- NAS MotionNotifier 轮询 SS `EventCenter.Event.List`(60s,camera_ids=2, event_types=10)推送
|
- NAS MotionNotifier 轮询 SS `EventCenter.Event.List`(60s,camera_ids=2, event_types=10)推送
|
||||||
/api/ss/motion;游标 MariaDB 续用 + 失败批次不前进 + 心跳。Webhook 端点保留为可选补充。
|
/api/ss/motion;游标 MariaDB 续用 + 失败批次不前进 + 心跳。Webhook 端点保留为可选补充。
|
||||||
- **SS duration=0 = 动作进行中**(结束才回填真实时长),分割只处理已结束事件。
|
- **SS duration=0 = 动作进行中**(结束才回填真实时长),分割只处理已结束事件。
|
||||||
@@ -18,10 +21,11 @@
|
|||||||
- NAS (192.168.50.64):SSH 2222, ericwyuan/iLoveJava5;fam-core 代码 `/volume1/web/sentinel-home-ai`,
|
- NAS (192.168.50.64):SSH 2222, ericwyuan/iLoveJava5;fam-core 代码 `/volume1/web/sentinel-home-ai`,
|
||||||
venv `fam-core/venv`,`start_core.sh` source .env;重启 = kill gunicorn + `setsid bash start_core.sh`
|
venv `fam-core/venv`,`start_core.sh` source .env;重启 = kill gunicorn + `setsid bash start_core.sh`
|
||||||
(NAS 无 systemd 守护)。
|
(NAS 无 systemd 守护)。
|
||||||
- **Oracle (129.146.203.203)**:SSH key `~/.ssh/oracle_sentinel`;fam-edge 由 **systemd
|
- **Oracle(云服务器 129.146.26.249)**:SSH key `~/.ssh/oracle_new`;fam-edge 由 **systemd
|
||||||
`fam-edge.service` 守护(Restart=always)**——部署代码后必须 `sudo systemctl restart fam-edge`,
|
`fam-edge.service` 守护(Restart=always)**——部署代码后必须 `sudo systemctl restart fam-edge`,
|
||||||
手动 setsid 会端口冲突;venv `/opt/fam-edge/venv`;库 `/opt/fam-edge/data/oracle.db`;
|
手动 setsid 会端口冲突;venv `/opt/fam-edge/venv`;库 `/opt/fam-edge/data/oracle.db`;
|
||||||
素材 `/opt/fam-edge/gdrive_videos`;logs 目录 ubuntu 可写。
|
素材 `/opt/fam-edge/gdrive_videos`;logs 目录 ubuntu 可写。
|
||||||
|
**前端亦托管于此机 Caddy :80**(fam-ui SPA + /api 反代回 NAS fam-core :8000 经 frp 隧道);旧机 129.146.203.203 已退役。
|
||||||
- 部署:本地 git 提交 push Gitea → tar 管道(NAS 直接解包;Oracle `--strip-components=1` 到临时目录再 cp,避免动 data/venv)。
|
- 部署:本地 git 提交 push Gitea → tar 管道(NAS 直接解包;Oracle `--strip-components=1` 到临时目录再 cp,避免动 data/venv)。
|
||||||
|
|
||||||
## 提交规范(强制)
|
## 提交规范(强制)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
|
|
||||||
## 环境状态
|
## 环境状态
|
||||||
|
|
||||||
### Oracle Cloud (129.146.203.203) - FAM-Edge 节点
|
### Oracle Cloud (129.146.26.249) - FAM-Edge 节点
|
||||||
| 项目 | 状态 | 备注 |
|
| 项目 | 状态 | 备注 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| OS | Ubuntu 20.04 ARM64 | Ampere A1 2C12G |
|
| OS | Ubuntu 20.04 ARM64 | Ampere A1 2C12G |
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
5. **MariaDB JSON 路径兼容** - MariaDB 10.11 不支持 MySQL 的 `$[*]` 通配符 JSON 路径和 `->` 操作符,name_member() 改用 Python 层解析 + 逐行 UPDATE
|
5. **MariaDB JSON 路径兼容** - MariaDB 10.11 不支持 MySQL 的 `$[*]` 通配符 JSON 路径和 `->` 操作符,name_member() 改用 Python 层解析 + 逐行 UPDATE
|
||||||
6. **Python 3.10 venv** - NAS 系统 Python 3.8 过旧,用 Synology Python3.10 包创建 venv
|
6. **Python 3.10 venv** - NAS 系统 Python 3.8 过旧,用 Synology Python3.10 包创建 venv
|
||||||
7. **FAM-Edge 聊天代理** - 历史:/api/edge/chat 直连 Ollama 代理;架构重构后被 `/api/edge/chat/ask` 三模型编排端点取代(旧端点保留兼容)
|
7. **FAM-Edge 聊天代理** - 历史:/api/edge/chat 直连 Ollama 代理;架构重构后被 `/api/edge/chat/ask` 三模型编排端点取代(旧端点保留兼容)
|
||||||
8. **Oracle 公网 IP 替代 Tailscale** - Tailscale 两节点在线但端口不通(防火墙),edge_url 和 qa_url 改用 Oracle 公网 IP 129.146.203.203
|
8. **Oracle 公网 IP 替代 Tailscale** - Tailscale 两节点在线但端口不通(防火墙),edge_url 和 qa_url 改用 Oracle 公网 IP 129.146.26.249
|
||||||
9. **Ollama 模型常驻内存** - systemd 加 `OLLAMA_KEEP_ALIVE=-1`,模型加载后永不卸载,消除 55s 冷启动延迟,常驻占用 4.3GB 内存(系统 12GB 够用)
|
9. **Ollama 模型常驻内存** - systemd 加 `OLLAMA_KEEP_ALIVE=-1`,模型加载后永不卸载,消除 55s 冷启动延迟,常驻占用 4.3GB 内存(系统 12GB 够用)
|
||||||
10. **关键帧自适应帧数** - 原固定 5-8 帧对长视频太稀疏(30分钟仅8帧=每3.75分钟1帧),改为随视频时长自适应:候选帧 `clamp(duration_min×2, 30, 120)`,关键帧上限 `clamp(duration/150s, 8, 30)`。30分钟→12帧,60分钟→24帧,封顶30帧
|
10. **关键帧自适应帧数** - 原固定 5-8 帧对长视频太稀疏(30分钟仅8帧=每3.75分钟1帧),改为随视频时长自适应:候选帧 `clamp(duration_min×2, 30, 120)`,关键帧上限 `clamp(duration/150s, 8, 30)`。30分钟→12帧,60分钟→24帧,封顶30帧
|
||||||
11. **云端直出直存(架构重构)** - 本地 Ollama 完全移出视频链路:云端 VLM(Gemini 多图单请求 / NVIDIA 逐帧聚合)直接产出结构化 JSON,Edge 仅 `format_cloud_result` 格式化/校验(无模型调用)后直存 NAS DB。根因:CPU 版 Ollama 对无上限融合 prompt 预填充极慢导致 300s 超时
|
11. **云端直出直存(架构重构)** - 本地 Ollama 完全移出视频链路:云端 VLM(Gemini 多图单请求 / NVIDIA 逐帧聚合)直接产出结构化 JSON,Edge 仅 `format_cloud_result` 格式化/校验(无模型调用)后直存 NAS DB。根因:CPU 版 Ollama 对无上限融合 prompt 预填充极慢导致 300s 超时
|
||||||
@@ -378,7 +378,7 @@ AI 分析瓶颈: 帧5耗时 229s (疑似 ARM CPU 热降频), 其余帧 50-65s
|
|||||||
/api/edge/chat/ask run_qa 三模型降级编排
|
/api/edge/chat/ask run_qa 三模型降级编排
|
||||||
```
|
```
|
||||||
|
|
||||||
- FAM-Core Chat-Handler 调用 `qa_url` 配置的 `http://129.146.203.203:5000/api/edge/chat/ask`(不再直连 Ollama)
|
- FAM-Core Chat-Handler 调用 `qa_url` 配置的 `http://129.146.26.249:5000/api/edge/chat/ask`(不再直连 Ollama)
|
||||||
- FAM-Edge `run_qa` 按 Gemini → NVIDIA → 本地 Ollama 顺序调用 `chat()`,首个成功即返回 `{answer, provider}`
|
- FAM-Edge `run_qa` 按 Gemini → NVIDIA → 本地 Ollama 顺序调用 `chat()`,首个成功即返回 `{answer, provider}`
|
||||||
- **验证结果**:
|
- **验证结果**:
|
||||||
- Edge 单测:`/api/edge/chat/ask` → 200,`provider=nvidia`(Gemini 30s 超时后 NVIDIA 兜底成功,耗时 51s)
|
- Edge 单测:`/api/edge/chat/ask` → 200,`provider=nvidia`(Gemini 30s 超时后 NVIDIA 兜底成功,耗时 51s)
|
||||||
|
|||||||
39
README.md
39
README.md
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
> 多模型容灾降级 + 交互式命名 + AI 对话的家庭监控系统。
|
> 多模型容灾降级 + 交互式命名 + AI 对话的家庭监控系统。
|
||||||
> 本文档为项目需求文档与 README 的整合版,按当前代码实际状态(v1.0 E2E 已打通)编写。
|
> 本文档为项目需求文档与 README 的整合版,按当前代码实际状态(v1.0 E2E 已打通)编写。
|
||||||
> 最后更新:2026-08-24(事件时间轴支持删除视频会话)
|
> 最后更新:2026-08-25(Oracle 迁至新服务器 129.146.26.249;前端由 NAS 迁至云服务器 Caddy :80)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
- **FAM-Core**(NAS 端单进程):**Oracle-Sync**(每 30 分钟拉增量镜像)+ **MotionNotifier**(轮询 SS 运动事件推送 Oracle)+ **Chat-Handler** + **Member-Manager**,CPU 占用极低
|
- **FAM-Core**(NAS 端单进程):**Oracle-Sync**(每 30 分钟拉增量镜像)+ **MotionNotifier**(轮询 SS 运动事件推送 Oracle)+ **Chat-Handler** + **Member-Manager**,CPU 占用极低
|
||||||
- **FAM-Edge**(Oracle 端单进程):rclone 实时同步 Google 硬盘视频(**整段素材**)→ 按 `ss_motion_events` 运动事件(start_time/duration)**ffmpeg 分割运动片段** → **只把运动片段送云端 VLM**(Gemini 用 Files API / NVIDIA 整视频 `video_url`)→ 结构化 JSON 落本地 SQLite → 对外提供 `/api/oracle/sync` 增量拉取接口(**本地模型不参与视频分析**)
|
- **FAM-Edge**(Oracle 端单进程):rclone 实时同步 Google 硬盘视频(**整段素材**)→ 按 `ss_motion_events` 运动事件(start_time/duration)**ffmpeg 分割运动片段** → **只把运动片段送云端 VLM**(Gemini 用 Files API / NVIDIA 整视频 `video_url`)→ 结构化 JSON 落本地 SQLite → 对外提供 `/api/oracle/sync` 增量拉取接口(**本地模型不参与视频分析**)
|
||||||
- **FAM-UI**(NAS 端):Vue3 SPA 读本地同步镜像(sync_videos / sync_events / sync_people),事件时间轴 + 人物管理 + AI 对话 + 对话历史 + 统计
|
- **FAM-UI**(云服务器端,由 Caddy :80 托管):Vue3 SPA 读本地同步镜像(sync_videos / sync_events / sync_people),事件时间轴 + 人物管理 + AI 对话 + 对话历史 + 统计;浏览器经云服务器访问,/api 经 frp 隧道反代回 NAS FAM-Core
|
||||||
- **数据库**:Oracle 侧 SQLite(videos/events/people/ss_motion_events/sync_cursor);NAS 侧 MariaDB 镜像(sync_videos / sync_events / sync_people / sync_cursor)+ chat_history
|
- **数据库**:Oracle 侧 SQLite(videos/events/people/ss_motion_events/sync_cursor);NAS 侧 MariaDB 镜像(sync_videos / sync_events / sync_people / sync_cursor)+ chat_history
|
||||||
- **数据流向**:Google 硬盘 ──rclone──► 甲骨文整段素材 ──按运动事件分割片段──► 片段云端分析 ──► Oracle SQLite ──每 30 分钟 NAS 拉取──► NAS MariaDB 镜像 ──► FAM-UI;运动事件由 NAS 轮询 SS 推送 Oracle(NAS → Oracle 单向)
|
- **数据流向**:Google 硬盘 ──rclone──► 甲骨文整段素材 ──按运动事件分割片段──► 片段云端分析 ──► Oracle SQLite ──每 30 分钟 NAS 拉取──► NAS MariaDB 镜像 ──► FAM-UI;运动事件由 NAS 轮询 SS 推送 Oracle(NAS → Oracle 单向)
|
||||||
- **AI 对话**:查 sync_events 拼上下文 → FAM-Edge 转发到独立 **ai-gateway** 服务(OpenAI 兼容协议,NVIDIA → Gemini → 本地 Ollama 降级链在 ai-gateway 内部完成,FAM-Edge 不再自己维护模型链)→ 返回并写 chat_history
|
- **AI 对话**:查 sync_events 拼上下文 → FAM-Edge 转发到独立 **ai-gateway** 服务(OpenAI 兼容协议,NVIDIA → Gemini → 本地 Ollama 降级链在 ai-gateway 内部完成,FAM-Edge 不再自己维护模型链)→ 返回并写 chat_history
|
||||||
@@ -63,13 +63,13 @@ 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(Vue3 构建产物,由 FAM-Core 托管), MariaDB :3306, Surveillance Station :5000 |
|
| Synology NAS | FAM-Core + 数据库(采集 + 聚合 + 镜像层) | DS220+ (Geminilake), DSM 7 | 家庭局域网 192.168.50.64 | FAM-Core :8000(API 聚合 + 轮询 SS + OracleSync), MariaDB :3306, Surveillance Station :5000;FAM-UI 已迁至云服务器(见下) |
|
||||||
| Oracle Cloud | FAM-Edge + AI-Gateway | Ampere A1 2C12G ARM64(无 GPU), Ubuntu 20.04 | 公网 129.146.203.203 / Tailscale 100.74.137.126 | FAM-Edge :5000(systemd 守护,视频分析 + 转发问答), **AI-Gateway :5100(systemd 守护,对外监听,问答模型降级链)**, Ollama :11434(仅本地,被 AI-Gateway 调用) |
|
| Oracle Cloud(云服务器) | FAM-Edge + AI-Gateway + Caddy(前端) | Ampere A1 4C23G ARM64(无 GPU), Ubuntu 20.04 | 公网 129.146.26.249 / Tailscale(已安装未启用,备用) | FAM-Edge :5000(systemd 守护,视频分析 + 转发问答), **AI-Gateway :5100(systemd 守护,对外监听,问答模型降级链)**, Caddy :80(托管 FAM-UI SPA + 反代 /api/* → NAS FAM-Core), Ollama :11434(仅本地,被 AI-Gateway 调用) |
|
||||||
| 家庭网络 | 用户入口 | 普通终端 | 192.168.50.0/24 | 浏览器访问 `http://192.168.50.64:8000` |
|
| 家庭网络 | 用户入口 | 普通终端 | 公网 / 家庭网络 | 浏览器访问 `http://129.146.26.249/`(Caddy 托管前端,/api 经 frp 隧道回源 NAS FAM-Core :8000) |
|
||||||
|
|
||||||
**网络要点(推送模式)**:
|
**网络要点(推送模式)**:
|
||||||
- 服务间通信只有两条出站:**NAS → Oracle 公网 IP:5000**(① `GET /api/oracle/sync` 拉增量 + `POST /api/oracle/people/correct` 命名回推 ② `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
|
||||||
- **运动事件获取(NAS 轮询驱动)**:FAM-Core MotionNotifier 每 60s 轮询本机 Surveillance Station 事件列表 API(`SYNO.SurveillanceStation.EventCenter.Event`),增量推送 Oracle。`/api/ss/webhook` 端点在代码中保留为可选低延迟补充(SS 行動規則 Webhook 未配置则不触发)
|
- **运动事件获取(NAS 轮询驱动)**:FAM-Core MotionNotifier 每 60s 轮询本机 Surveillance Station 事件列表 API(`SYNO.SurveillanceStation.EventCenter.Event`),增量推送 Oracle。`/api/ss/webhook` 端点在代码中保留为可选低延迟补充(SS 行動規則 Webhook 未配置则不触发)。若启用 SS Webhook,目标地址改为云服务器公网 `http://129.146.26.249/api/ss/webhook?token=ORACLE_SYNC_TOKEN`。
|
||||||
- Oracle 端 Ollama 端口 11434 不对外暴露;聊天请求 NAS → FAM-Edge `/api/edge/chat/ask`(保持不变的对外契约)→ **FAM-Edge 转发到同机的 AI-Gateway :5100**(`/v1/chat/completions`,OpenAI 兼容协议,Bearer token 鉴权)→ NVIDIA/Gemini/Ollama 降级链。AI-Gateway 自己对外监听 0.0.0.0:5100(Bearer token 鉴权 fail-closed),供其他项目直接接入,不止服务本系统
|
- Oracle 端 Ollama 端口 11434 不对外暴露;聊天请求 NAS → FAM-Edge `/api/edge/chat/ask`(保持不变的对外契约)→ **FAM-Edge 转发到同机的 AI-Gateway :5100**(`/v1/chat/completions`,OpenAI 兼容协议,Bearer token 鉴权)→ NVIDIA/Gemini/Ollama 降级链。AI-Gateway 自己对外监听 0.0.0.0:5100(Bearer token 鉴权 fail-closed),供其他项目直接接入,不止服务本系统
|
||||||
- Tailscale 两节点已安装在线,但 NAS tailscaled 为 userspace 模式且防火墙端口不通,暂走公网 IP
|
- Tailscale 两节点已安装在线,但 NAS tailscaled 为 userspace 模式且防火墙端口不通,暂走公网 IP
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA N
|
|||||||
└──────────────┬───────────────────────┘
|
└──────────────┬───────────────────────┘
|
||||||
│ rclone 定时同步(systemd timer)
|
│ rclone 定时同步(systemd timer)
|
||||||
▼
|
▼
|
||||||
┌──────────────────────── Oracle Cloud (129.146.203.203) ────────────────────────┐
|
┌──────────────────────── Oracle Cloud / 云服务器 (129.146.26.249) ────────────────────────┐
|
||||||
│ FAM-Edge (Flask :5000) │
|
│ FAM-Edge (Flask :5000) │
|
||||||
│ ├─ Video-Queue: 30s 轮询 /opt/fam-edge/gdrive_videos 登记整段素材 │
|
│ ├─ Video-Queue: 30s 轮询 /opt/fam-edge/gdrive_videos 登记整段素材 │
|
||||||
│ ├─ Video-Processor: 素材按 ss_motion_events 分割运动片段 → 只分析片段 │
|
│ ├─ Video-Processor: 素材按 ss_motion_events 分割运动片段 → 只分析片段 │
|
||||||
@@ -98,6 +98,7 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA N
|
|||||||
│ ├─ /v1/chat/completions:NVIDIA → Gemini(多 Key 轮换)→ 本地 Ollama 降级链 │
|
│ ├─ /v1/chat/completions:NVIDIA → Gemini(多 Key 轮换)→ 本地 Ollama 降级链 │
|
||||||
│ ├─ Bearer token 鉴权(fail-closed),对外 0.0.0.0:5100,非本系统专属 │
|
│ ├─ Bearer token 鉴权(fail-closed),对外 0.0.0.0:5100,非本系统专属 │
|
||||||
│ └─ 独立 .env(/opt/ai-gateway/.env,NVIDIA/Gemini key 与 FAM-Edge 各自一份) │
|
│ └─ 独立 .env(/opt/ai-gateway/.env,NVIDIA/Gemini key 与 FAM-Edge 各自一份) │
|
||||||
|
│ Caddy :80:托管 FAM-UI SPA + 反代 /api/* → NAS FAM-Core(frp 隧道 :8000) │
|
||||||
└───────────────────────────────┬───────────────────────────────────────────────┘
|
└───────────────────────────────┬───────────────────────────────────────────────┘
|
||||||
│ HTTP GET /api/oracle/sync?since=&token= (每 30 分钟)
|
│ HTTP GET /api/oracle/sync?since=&token= (每 30 分钟)
|
||||||
▼
|
▼
|
||||||
@@ -109,7 +110,7 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA N
|
|||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ MariaDB (sentinel_home_ai): sync_videos / sync_events / sync_people / │
|
│ MariaDB (sentinel_home_ai): sync_videos / sync_events / sync_people / │
|
||||||
│ sync_cursor / chat_history │
|
│ sync_cursor / chat_history │
|
||||||
│ FAM-UI (Vue3 SPA,FAM-Core 托管 dist) 读同步镜像 │
|
│ (FAM-UI 已迁至云服务器 Caddy :80,见上) │
|
||||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -544,9 +545,9 @@ task_id=289(30s 测试片段)全链路打通:推送 5.7MB → Edge 分析
|
|||||||
| **AI-Gateway** | Oracle | `/opt/ai-gateway/` | **systemd `ai-gateway.service` 守护**(Restart=always),独立 venv(Python 3.8);gunicorn 绑定 `0.0.0.0:5100`(对外直接开放,非仅本机);部署代码后 `sudo systemctl restart ai-gateway` |
|
| **AI-Gateway** | Oracle | `/opt/ai-gateway/` | **systemd `ai-gateway.service` 守护**(Restart=always),独立 venv(Python 3.8);gunicorn 绑定 `0.0.0.0:5100`(对外直接开放,非仅本机);部署代码后 `sudo systemctl restart ai-gateway` |
|
||||||
| Ollama | Oracle | systemd 托管 | 环境变量 `OLLAMA_KEEP_ALIVE=-1`;**被 AI-Gateway 调用,不再被 FAM-Edge 调用** |
|
| Ollama | Oracle | systemd 托管 | 环境变量 `OLLAMA_KEEP_ALIVE=-1`;**被 AI-Gateway 调用,不再被 FAM-Edge 调用** |
|
||||||
|
|
||||||
> **外网访问(frp 内网穿透)**:NAS 跑 `frpc`(`/etc/frp/frpc.toml`,S99frpc.sh 守护),映射到 Oracle `129.146.203.203`(frps :7000):
|
> **外网访问(frp 内网穿透)**:NAS 跑 `frpc`(`/etc/frp/frpc.toml`,S99frpc.sh 守护),映射到云服务器 `129.146.26.249`(frps :7000):
|
||||||
> - `3000` → NAS Gitea、`8500` → NAS WordPress(8088)、**`8000` → NAS FAM-Core(本系统)**
|
> - `3000` → NAS Gitea、`8500` → NAS WordPress(8088)、**`8000` → NAS FAM-Core(本系统)**
|
||||||
> - 外网入口 `http://129.146.203.203:8000`,**需登录**(见 §5.2 `/api/login`)
|
> - 前端入口 `http://129.146.26.249/`(Caddy 托管 FAM-UI,/api 经 frp 隧道反代回 NAS FAM-Core :8000),**需登录**(见 §5.2 `/api/login`)
|
||||||
>
|
>
|
||||||
> **登录校验(2026-08-22 新增,2026-08-23 改为 fail-closed)**:FAM-Core 全站拦截(`auth.py`)——页面未登录 302 `/login`(内置深色登录页,SPA 零改动),`/api/*` 未登录 401;凭据 `FAM_AUTH_USER`/`FAM_AUTH_PASS`(NAS `.env` 配置,**无硬编码默认值**——这两个变量跟 NAS SSH 密码是同一个值,公网入口不能有"没配置就退回已知密码"的兜底,`.env` 没配好这两项时直接拒绝所有登录);白名单免登录:`/login` `/api/login` `/api/logout` `/api/auth/check` `/health` `/api/ss/webhook` `/assets/*`。登录态为进程内 token + HttpOnly cookie(**2 小时**),过期或重启 fam-core 需重新登录。
|
> **登录校验(2026-08-22 新增,2026-08-23 改为 fail-closed)**:FAM-Core 全站拦截(`auth.py`)——页面未登录 302 `/login`(内置深色登录页,SPA 零改动),`/api/*` 未登录 401;凭据 `FAM_AUTH_USER`/`FAM_AUTH_PASS`(NAS `.env` 配置,**无硬编码默认值**——这两个变量跟 NAS SSH 密码是同一个值,公网入口不能有"没配置就退回已知密码"的兜底,`.env` 没配好这两项时直接拒绝所有登录);白名单免登录:`/login` `/api/login` `/api/logout` `/api/auth/check` `/health` `/api/ss/webhook` `/assets/*`。登录态为进程内 token + HttpOnly cookie(**2 小时**),过期或重启 fam-core 需重新登录。
|
||||||
|
|
||||||
@@ -570,11 +571,11 @@ server:
|
|||||||
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.26.249: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 分钟拉一次增量
|
||||||
chat_handler:
|
chat_handler:
|
||||||
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask"
|
qa_url: "http://129.146.26.249:5000/api/edge/chat/ask"
|
||||||
timeout: 120
|
timeout: 120
|
||||||
motion_notifier: # 运动监测(轮询主路径)
|
motion_notifier: # 运动监测(轮询主路径)
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -585,7 +586,7 @@ motion_notifier: # 运动监测(轮询主路径)
|
|||||||
dsm_password: "${DSM_PASSWORD}"
|
dsm_password: "${DSM_PASSWORD}"
|
||||||
camera_ids: [2]
|
camera_ids: [2]
|
||||||
camera_name_to_id: {"Generic_ONVIF-001": 2}
|
camera_name_to_id: {"Generic_ONVIF-001": 2}
|
||||||
oracle_base_url: "http://129.146.203.203:5000"
|
oracle_base_url: "http://129.146.26.249:5000"
|
||||||
oracle_token: "${ORACLE_SYNC_TOKEN}"
|
oracle_token: "${ORACLE_SYNC_TOKEN}"
|
||||||
poll_interval_sec: 60
|
poll_interval_sec: 60
|
||||||
poll_window_hours: 2
|
poll_window_hours: 2
|
||||||
@@ -720,7 +721,7 @@ cd /volume1/web/sentinel-home-ai && bash start_core.sh
|
|||||||
# 3. 启动 FAM-Edge(Oracle,systemd 守护)
|
# 3. 启动 FAM-Edge(Oracle,systemd 守护)
|
||||||
sudo systemctl restart fam-edge
|
sudo systemctl restart fam-edge
|
||||||
|
|
||||||
# 4. 前端(Vue3,构建后由 FAM-Core 托管,无需独立进程)
|
# 4. 前端(Vue3,构建后由云服务器 Caddy :80 托管,/api 经 frp 隧道反代回 NAS FAM-Core,无需在 NAS 独立进程)
|
||||||
cd fam-ui && npm run build # 产物 fam-ui/dist/
|
cd fam-ui && npm run build # 产物 fam-ui/dist/
|
||||||
|
|
||||||
# 或使用脚本
|
# 或使用脚本
|
||||||
@@ -756,11 +757,11 @@ cd fam-ui && npm run build # 产物 fam-ui/dist/
|
|||||||
|
|
||||||
| 项目 | 值 |
|
| 项目 | 值 |
|
||||||
|------|-----|
|
|------|-----|
|
||||||
| 公网 IP | 129.146.203.203 |
|
| 公网 IP | 129.146.26.249 |
|
||||||
| SSH 用户 | ubuntu(密钥 `~/.ssh/oracle_sentinel`) |
|
| SSH 用户 | ubuntu(密钥 `~/.ssh/oracle_new`) |
|
||||||
| 系统 | aarch64 (Ampere A1 2C12G), Ubuntu 20.04 LTS |
|
| 系统 | aarch64 (Ampere A1 2C12G), Ubuntu 20.04 LTS |
|
||||||
| Tailscale | 100.74.137.126(已安装在线,与 NAS 端口不通) |
|
| Tailscale | 已安装未启用(备用;新机公网直连为主) |
|
||||||
| 登录命令 | `ssh -i ~/.ssh/oracle_sentinel ubuntu@129.146.203.203` |
|
| 登录命令 | `ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249` |
|
||||||
|
|
||||||
### 10.4 Gitea 代码仓库
|
### 10.4 Gitea 代码仓库
|
||||||
|
|
||||||
@@ -836,7 +837,7 @@ export NVIDIA_API_KEY="nvapi-9cFAdO5xdbwPuxS8KGRTnlVimn1gJzbbbzWNhPwHa_Yl3pTe-Pf
|
|||||||
- **运动事件驱动**(v3,2026-08-22):不再分析整段视频。NAS MotionNotifier 轮询 SS 事件列表(60s,游标续用/失败重试/心跳)推送 `ss_motion_events`;Oracle 整段素材按运动事件 ffmpeg 分割运动片段(`-c:v copy -c:a aac`,只分割已结束事件,`motion_event_id` 幂等),**只分析运动片段**;前端契约不变
|
- **运动事件驱动**(v3,2026-08-22):不再分析整段视频。NAS MotionNotifier 轮询 SS 事件列表(60s,游标续用/失败重试/心跳)推送 `ss_motion_events`;Oracle 整段素材按运动事件 ffmpeg 分割运动片段(`-c:v copy -c:a aac`,只分割已结束事件,`motion_event_id` 幂等),**只分析运动片段**;前端契约不变
|
||||||
- **数据迁移**(2026-08-22):整段提取的旧数据(Oracle videos/events/people + NAS 镜像)已清空并按运动视频重新提取;历史素材(NAS 轮询启动前)无运动事件,时间轴已过滤其空会话
|
- **数据迁移**(2026-08-22):整段提取的旧数据(Oracle videos/events/people + NAS 镜像)已清空并按运动视频重新提取;历史素材(NAS 轮询启动前)无运动事件,时间轴已过滤其空会话
|
||||||
- **人物管理重设计**(2026-08-22):人物卡新增「运动片段」区块(缩略图/时间/摘要/事件数,点击跳时间轴定位);新增 `GET /api/ui/people/clips`;Timeline 支持 `?video=` 定位
|
- **人物管理重设计**(2026-08-22):人物卡新增「运动片段」区块(缩略图/时间/摘要/事件数,点击跳时间轴定位);新增 `GET /api/ui/people/clips`;Timeline 支持 `?video=` 定位
|
||||||
- 前端从 Streamlit 迁移为 **Vue3 + Vite + Tailwind SPA**(由 FAM-Core 托管 dist)
|
- 前端从 Streamlit 迁移为 **Vue3 + Vite + Tailwind SPA**;2026-08-25 起前端托管由 NAS 迁至云服务器 Caddy :80,/api 经 frp 隧道反代回 NAS FAM-Core
|
||||||
- 运动事件链路(NAS→Oracle 单向推送)+ 心跳 fail-open;Gemini 多 Key 轮换 + 模型调用统计
|
- 运动事件链路(NAS→Oracle 单向推送)+ 心跳 fail-open;Gemini 多 Key 轮换 + 模型调用统计
|
||||||
- 双端部署:NAS `start_core.sh`(source .env);Oracle **systemd `fam-edge.service` 守护**
|
- 双端部署:NAS `start_core.sh`(source .env);Oracle **systemd `fam-edge.service` 守护**
|
||||||
|
|
||||||
|
|||||||
@@ -27,15 +27,15 @@ cp config/config.yaml.example config/config.yaml
|
|||||||
cd /volume1/web/sentinel-home-ai && bash start_core.sh
|
cd /volume1/web/sentinel-home-ai && bash start_core.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.3 FAM-UI(Vue3 SPA,无独立进程)
|
### 1.3 FAM-UI(Vue3 SPA,托管于云服务器 Caddy :80)
|
||||||
```bash
|
```bash
|
||||||
cd /Users/ericwyuan/Desktop/Work/sentinel-home-ai/fam-ui # 本地开发机
|
cd /Users/ericwyuan/Desktop/Work/sentinel-home-ai/fam-ui # 本地开发机
|
||||||
npm install
|
npm install
|
||||||
npm run build # 产物 fam-ui/dist/
|
npm run build # 产物 fam-ui/dist/
|
||||||
# 部署 dist 到 NAS(构建产物不入库):
|
# 部署 dist 到云服务器(Caddy 静态目录,/api 经 frp 隧道反代回 NAS):
|
||||||
tar czf - fam-ui/dist | ssh -p 2222 ericwyuan@192.168.50.64 \
|
tar czf - fam-ui/dist | ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249 \
|
||||||
'cd /volume1/web/sentinel-home-ai && rm -rf fam-ui/dist && tar xzf -'
|
'sudo tar xzf - --strip-components=2 -C /var/www/fam-ui && sudo chown -R ubuntu:ubuntu /var/www/fam-ui'
|
||||||
# 浏览器访问 http://192.168.50.64:8000(由 FAM-Core static_app.py 托管,Vue Router history 回退)
|
# 浏览器访问 http://129.146.26.249/(Caddy 托管,/api/* 反代 NAS fam-core)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Oracle 端部署 (FAM-Edge + Ollama + FFmpeg)
|
## 2. Oracle 端部署 (FAM-Edge + Ollama + FFmpeg)
|
||||||
@@ -73,7 +73,7 @@ tar czf - --exclude=venv --exclude=__pycache__ fam-core | \
|
|||||||
|
|
||||||
# Oracle(fam-edge):/opt/fam-edge 是 fam-edge 根,--strip-components=1 解临时目录再 cp
|
# 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 | \
|
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 && \
|
ssh ubuntu@129.146.26.249 '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'
|
cp -rf /opt/fam-edge/tmp_d/* /opt/fam-edge/ && rm -rf /opt/fam-edge/tmp_d && sudo systemctl restart fam-edge'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ database:
|
|||||||
# 甲骨文同步(每 30 分钟拉增量镜像)
|
# 甲骨文同步(每 30 分钟拉增量镜像)
|
||||||
oracle_sync:
|
oracle_sync:
|
||||||
# FAM-Edge 对外同步接口地址(端口同其 server.port=5000)
|
# FAM-Edge 对外同步接口地址(端口同其 server.port=5000)
|
||||||
base_url: "http://129.146.203.203:5000"
|
base_url: "http://129.146.26.249:5000"
|
||||||
# 与 Oracle 端 sync_api.token 一致(环境变量注入,避免明文入库)
|
# 与 Oracle 端 sync_api.token 一致(环境变量注入,避免明文入库)
|
||||||
token: "${ORACLE_SYNC_TOKEN}"
|
token: "${ORACLE_SYNC_TOKEN}"
|
||||||
interval_sec: 1800 # 拉取间隔(秒),默认 30 分钟
|
interval_sec: 1800 # 拉取间隔(秒),默认 30 分钟
|
||||||
@@ -30,14 +30,13 @@ oracle_sync:
|
|||||||
|
|
||||||
chat_handler:
|
chat_handler:
|
||||||
# 智能问答统一走 FAM-Edge 编排端点(Gemini → NVIDIA → 本地 Ollama 兜底)
|
# 智能问答统一走 FAM-Edge 编排端点(Gemini → NVIDIA → 本地 Ollama 兜底)
|
||||||
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask"
|
qa_url: "http://129.146.26.249:5000/api/edge/chat/ask"
|
||||||
qa_stream_url: "http://129.146.203.203:5000/api/edge/chat/ask/stream" # 流式版
|
qa_stream_url: "http://129.146.26.249:5000/api/edge/chat/ask/stream" # 流式版
|
||||||
timeout: 120
|
timeout: 120
|
||||||
|
|
||||||
# 运动监测通知服务(2026-08-22 定稿:轮询主路径)
|
# 运动监测通知服务(2026-08-22 定稿:轮询主路径;2026-08-25 移除 Webhook 可选路径)
|
||||||
# NAS 本机轮询 SS EventCenter.Event.List(真实 event_id/start_time/duration),
|
# NAS 本机轮询 SS EventCenter.Event.List(真实 event_id/start_time/duration),
|
||||||
# 增量推送到甲骨文 FAM-Edge(单向 NAS -> Oracle,甲骨文不再反向访问 NAS)。
|
# 增量推送到甲骨文 FAM-Edge(单向 NAS -> Oracle,甲骨文不再反向访问 NAS)。
|
||||||
# Webhook(/api/ss/webhook)保留为可选低延迟补充,SS 行动规则未配置则不触发。
|
|
||||||
# SS 凭据走环境变量(${DSM_ACCOUNT}/${DSM_PASSWORD}),由启动脚本 source 的 .env 提供。
|
# SS 凭据走环境变量(${DSM_ACCOUNT}/${DSM_PASSWORD}),由启动脚本 source 的 .env 提供。
|
||||||
motion_notifier:
|
motion_notifier:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -47,10 +46,7 @@ motion_notifier:
|
|||||||
dsm_account: "${DSM_ACCOUNT}"
|
dsm_account: "${DSM_ACCOUNT}"
|
||||||
dsm_password: "${DSM_PASSWORD}"
|
dsm_password: "${DSM_PASSWORD}"
|
||||||
camera_ids: [2] # 轮询关注的摄像头(Generic_ONVIF-001)
|
camera_ids: [2] # 轮询关注的摄像头(Generic_ONVIF-001)
|
||||||
# 摄像头名 -> camera_id 静态映射(Webhook 路径用;启动时还会从 SS 拉一份补全)
|
oracle_base_url: "http://129.146.26.249:5000" # 与 oracle_sync.base_url 一致
|
||||||
camera_name_to_id:
|
|
||||||
"Generic_ONVIF-001": 2
|
|
||||||
oracle_base_url: "http://129.146.203.203:5000" # 与 oracle_sync.base_url 一致
|
|
||||||
oracle_token: "${ORACLE_SYNC_TOKEN}" # 与 oracle_sync.token 一致
|
oracle_token: "${ORACLE_SYNC_TOKEN}" # 与 oracle_sync.token 一致
|
||||||
timeout_sec: 10 # 单次 SS 请求超时
|
timeout_sec: 10 # 单次 SS 请求超时
|
||||||
# 心跳:跟轮询 SS 无关,只是定期空 POST 一下甲骨文的 /api/ss/motion,证明
|
# 心跳:跟轮询 SS 无关,只是定期空 POST 一下甲骨文的 /api/ss/motion,证明
|
||||||
@@ -62,7 +58,3 @@ motion_notifier:
|
|||||||
poll_interval_sec: 60 # 轮询间隔
|
poll_interval_sec: 60 # 轮询间隔
|
||||||
poll_window_hours: 2 # 每轮回看窗口(小时),覆盖轮询间隔内的新事件
|
poll_window_hours: 2 # 每轮回看窗口(小时),覆盖轮询间隔内的新事件
|
||||||
batch_size: 100 # 单批推送上限
|
batch_size: 100 # 单批推送上限
|
||||||
# Webhook 可选补全(仅 webhook 路径用):收到 webhook 后,以 camera_id+触发时刻
|
|
||||||
# 为中心开此时间窗(秒)回头查 SS 事件列表,匹配最接近的运动事件回填真实
|
|
||||||
# start_time/duration/event_id(Webhook 本身不提供这些字段)。
|
|
||||||
enrich_window_sec: 120
|
|
||||||
|
|||||||
@@ -23,22 +23,19 @@ from .member_manager.member_manager import member_bp
|
|||||||
from .img_proxy import img_bp
|
from .img_proxy import img_bp
|
||||||
from .motion_bp import motion_bp
|
from .motion_bp import motion_bp
|
||||||
from .ui_api import ui_bp
|
from .ui_api import ui_bp
|
||||||
from .static_app import static_bp
|
|
||||||
from .auth import auth_bp, init_auth
|
from .auth import auth_bp, init_auth
|
||||||
|
|
||||||
logger = setup_logger('fam-core.app')
|
logger = setup_logger('fam-core.app')
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# 注册蓝图:/api/* 系列必须先于 static_bp 注册——static_bp 是通配兜底路由
|
# 注册蓝图:全部 /api/* 路由(前端已迁云服务器 Caddy:80,NAS 不再托管 SPA 静态文件)
|
||||||
# (Vue Router history 模式回退 index.html),排在前面会吞掉 API 请求。
|
|
||||||
app.register_blueprint(chat_bp)
|
app.register_blueprint(chat_bp)
|
||||||
app.register_blueprint(member_bp)
|
app.register_blueprint(member_bp)
|
||||||
app.register_blueprint(img_bp)
|
app.register_blueprint(img_bp)
|
||||||
app.register_blueprint(motion_bp)
|
app.register_blueprint(motion_bp)
|
||||||
app.register_blueprint(ui_bp)
|
app.register_blueprint(ui_bp)
|
||||||
app.register_blueprint(auth_bp) # 登录页 /api/login 等(static_bp 通配之前)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(static_bp)
|
|
||||||
|
|
||||||
# 登录校验全局拦截(页面未登录 302 /login;/api/* 未登录 401)
|
# 登录校验全局拦截(页面未登录 302 /login;/api/* 未登录 401)
|
||||||
init_auth(app)
|
init_auth(app)
|
||||||
@@ -64,17 +61,11 @@ try:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Oracle-Sync 启动失败: {e}")
|
logger.error(f"Oracle-Sync 启动失败: {e}")
|
||||||
|
|
||||||
# 初始化运动监测通知服务(Webhook 驱动,不轮询;提供推送客户端 + 摄像头名映射)
|
# 初始化运动监测通知服务(NAS 轮询 SS EventCenter 主路径)
|
||||||
_notifier = None
|
_notifier = None
|
||||||
try:
|
try:
|
||||||
_notifier = get_motion_notifier()
|
_notifier = get_motion_notifier()
|
||||||
_notifier.start()
|
_notifier.start()
|
||||||
# 启动时一次性从 SS 拉取摄像头名->id 映射(非轮询),失败仅告警,Webhook 仍可运行
|
|
||||||
try:
|
|
||||||
_notifier.refresh_camera_map()
|
|
||||||
logger.info("MotionNotifier 摄像头映射已加载")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"摄像头映射加载失败(Webhook 仍可运行,camera_id 可能为空): {e}")
|
|
||||||
logger.info("MotionNotifier 已初始化")
|
logger.info("MotionNotifier 已初始化")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"MotionNotifier 初始化失败: {e}")
|
logger.error(f"MotionNotifier 初始化失败: {e}")
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ def is_authed() -> bool:
|
|||||||
_WHITELIST_EXACT = {
|
_WHITELIST_EXACT = {
|
||||||
'/login', '/api/login', '/api/logout', '/api/auth/check',
|
'/login', '/api/login', '/api/logout', '/api/auth/check',
|
||||||
'/health', '/favicon.ico',
|
'/health', '/favicon.ico',
|
||||||
'/api/ss/webhook', # SS 推送(无登录态,必须放行)
|
|
||||||
}
|
}
|
||||||
_WHITELIST_PREFIX = ('/assets/',)
|
_WHITELIST_PREFIX = ('/assets/',)
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ def _call_edge_qa(prompt: str) -> str:
|
|||||||
"""调用 FAM-Edge 问答编排端点(Gemini → NVIDIA → 本地 Ollama 兜底)"""
|
"""调用 FAM-Edge 问答编排端点(Gemini → NVIDIA → 本地 Ollama 兜底)"""
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
qa_url = cfg.get('chat_handler', {}).get(
|
qa_url = cfg.get('chat_handler', {}).get(
|
||||||
'qa_url', 'http://129.146.203.203:5000/api/edge/chat/ask'
|
'qa_url', 'http://129.146.26.249:5000/api/edge/chat/ask'
|
||||||
)
|
)
|
||||||
timeout = cfg.get('chat_handler', {}).get('timeout', 120)
|
timeout = cfg.get('chat_handler', {}).get('timeout', 120)
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ def chat_ask_stream():
|
|||||||
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
stream_url = cfg.get('chat_handler', {}).get(
|
stream_url = cfg.get('chat_handler', {}).get(
|
||||||
'qa_stream_url', 'http://129.146.203.203:5000/api/edge/chat/ask/stream')
|
'qa_stream_url', 'http://129.146.26.249:5000/api/edge/chat/ask/stream')
|
||||||
timeout = cfg.get('chat_handler', {}).get('timeout', 120)
|
timeout = cfg.get('chat_handler', {}).get('timeout', 120)
|
||||||
|
|
||||||
full_answer = []
|
full_answer = []
|
||||||
|
|||||||
@@ -1,29 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
运动监测 Webhook 接收(fam-core,可选补充,非主路径)
|
运动监测状态接口(fam-core)
|
||||||
|
|
||||||
主数据源是 MotionNotifier 轮询 SS EventCenter.Event.List(真实
|
运动事件采集走 MotionNotifier 轮询 SS EventCenter.Event.List(真实
|
||||||
event_id/start_time/duration)。本端点是可选的低延迟补充:若在群晖
|
event_id/start_time/duration),不再提供 /api/ss/webhook 接收端点
|
||||||
Surveillance Station 配置「行動規則 -> 事件=偵測到動作 -> 動作=Webhook」,
|
(SS Webhook 行动规则已弃用,2026-08-25 移除)。
|
||||||
SS 会把运动事件实时 POST 到本端点,本端点映射字段后走同一个
|
|
||||||
push_events_to_oracle 推送到甲骨文 FAM-Edge(数据方向 NAS -> Oracle 单向)。
|
|
||||||
|
|
||||||
SS Webhook 仅提供模板变量(无 event_id / camera_id 数字字段):
|
|
||||||
%EVENT_TIME% -> event_time (本地时间字符串)
|
|
||||||
%DEVICE_NAME% -> device_name (摄像头名,需映射到 camera_id)
|
|
||||||
%EVENT_NAME% -> event_name
|
|
||||||
%SERVER_NAME% -> server_name
|
|
||||||
%THUMBNAIL_URL% -> thumbnail_url
|
|
||||||
|
|
||||||
因此 SS 端配置 Webhook 时,请按以下「参数名 -> 模板变量」一一添加(参数名必须一致):
|
|
||||||
event_time = %EVENT_TIME%
|
|
||||||
device_name = %DEVICE_NAME%
|
|
||||||
event_name = %EVENT_NAME%
|
|
||||||
server_name = %SERVER_NAME%
|
|
||||||
thumbnail_url = %THUMBNAIL_URL%
|
|
||||||
|
|
||||||
本端点接受 JSON 或表单;支持单条对象 / {events:[...]} / 裸数组,字段名已做兼容。
|
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, jsonify
|
||||||
|
|
||||||
from .logger import setup_logger
|
from .logger import setup_logger
|
||||||
from .motion_notifier.motion_notifier import get_motion_notifier
|
from .motion_notifier.motion_notifier import get_motion_notifier
|
||||||
@@ -33,44 +15,6 @@ logger = setup_logger('fam-core.motion_bp')
|
|||||||
motion_bp = Blueprint('motion_bp', __name__)
|
motion_bp = Blueprint('motion_bp', __name__)
|
||||||
|
|
||||||
|
|
||||||
def _coerce_events(payload) -> list:
|
|
||||||
"""从 SS Webhook 各种形态中提取事件列表。"""
|
|
||||||
if isinstance(payload, list):
|
|
||||||
return payload
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
for key in ('events', 'data', 'event', 'items'):
|
|
||||||
v = payload.get(key)
|
|
||||||
if isinstance(v, list):
|
|
||||||
return v
|
|
||||||
# 单事件对象(表单字段即顶层 key)
|
|
||||||
return [payload]
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
@motion_bp.route('/api/ss/webhook', methods=['POST'])
|
|
||||||
def ss_webhook():
|
|
||||||
data = request.get_json(silent=True)
|
|
||||||
if not data:
|
|
||||||
data = request.form.to_dict() or None
|
|
||||||
if not data:
|
|
||||||
return jsonify({"error": "Invalid payload"}), 400
|
|
||||||
raw_events = _coerce_events(data)
|
|
||||||
if not raw_events:
|
|
||||||
return jsonify({"error": "no events found in payload"}), 400
|
|
||||||
notifier = get_motion_notifier()
|
|
||||||
norm = []
|
|
||||||
for raw in raw_events:
|
|
||||||
ev = notifier.build_event_from_webhook(raw)
|
|
||||||
if ev:
|
|
||||||
norm.append(ev)
|
|
||||||
if not norm:
|
|
||||||
return jsonify({"error": "no mappable events",
|
|
||||||
"received": len(raw_events)}), 400
|
|
||||||
pushed = notifier.push_events_to_oracle(norm)
|
|
||||||
return jsonify({"status": "ok", "received": len(raw_events),
|
|
||||||
"mapped": len(norm), "pushed": pushed}), 200
|
|
||||||
|
|
||||||
|
|
||||||
@motion_bp.route('/api/ss/status', methods=['GET'])
|
@motion_bp.route('/api/ss/status', methods=['GET'])
|
||||||
def ss_status():
|
def ss_status():
|
||||||
return jsonify(get_motion_notifier().status()), 200
|
return jsonify(get_motion_notifier().status()), 200
|
||||||
|
|||||||
@@ -18,15 +18,10 @@ MotionNotifier - NAS 端运动监测通知服务(轮询主路径,2026-08-22
|
|||||||
- 心跳线程(enabled 即跑,与轮询无关):定期空 POST /api/ss/motion,证明
|
- 心跳线程(enabled 即跑,与轮询无关):定期空 POST /api/ss/motion,证明
|
||||||
NAS->Oracle 推送链路存活,供甲骨文侧判断"无运动"结论是否可信。
|
NAS->Oracle 推送链路存活,供甲骨文侧判断"无运动"结论是否可信。
|
||||||
|
|
||||||
Webhook(可选补充,非主路径):
|
|
||||||
fam-core 仍暴露 POST /api/ss/webhook,SS 行动规则若配置可实时低延迟推送,
|
|
||||||
经 build_event_from_webhook 映射后与轮询共用 push_events_to_oracle;
|
|
||||||
其合成 event_id 与轮询真实 event_id 不冲突(不同命名空间,幂等互不干扰)。
|
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import hashlib
|
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -42,7 +37,7 @@ _MOTION_NOTIFIER = None
|
|||||||
|
|
||||||
|
|
||||||
def get_motion_notifier():
|
def get_motion_notifier():
|
||||||
"""模块级单例(app.py 启动时创建并 start,Webhook 路由经此获取)。"""
|
"""模块级单例(app.py 启动时创建并 start,状态接口经此获取)。"""
|
||||||
global _MOTION_NOTIFIER
|
global _MOTION_NOTIFIER
|
||||||
if _MOTION_NOTIFIER is None:
|
if _MOTION_NOTIFIER is None:
|
||||||
_MOTION_NOTIFIER = MotionNotifier()
|
_MOTION_NOTIFIER = MotionNotifier()
|
||||||
@@ -59,7 +54,7 @@ class MotionNotifier:
|
|||||||
self.dsm_password = self._resolve(cfg.get('dsm_password', ''))
|
self.dsm_password = self._resolve(cfg.get('dsm_password', ''))
|
||||||
self.camera_ids = cfg.get('camera_ids', [2])
|
self.camera_ids = cfg.get('camera_ids', [2])
|
||||||
self.oracle_base_url = cfg.get('oracle_base_url',
|
self.oracle_base_url = cfg.get('oracle_base_url',
|
||||||
'http://129.146.203.203:5000').rstrip('/')
|
'http://129.146.26.249:5000').rstrip('/')
|
||||||
self.oracle_token = self._resolve(cfg.get('oracle_token', '${ORACLE_SYNC_TOKEN}'))
|
self.oracle_token = self._resolve(cfg.get('oracle_token', '${ORACLE_SYNC_TOKEN}'))
|
||||||
self.poll_interval_sec = int(cfg.get('poll_interval_sec', 60))
|
self.poll_interval_sec = int(cfg.get('poll_interval_sec', 60))
|
||||||
self.poll_window_hours = int(cfg.get('poll_window_hours', 2))
|
self.poll_window_hours = int(cfg.get('poll_window_hours', 2))
|
||||||
@@ -74,14 +69,8 @@ class MotionNotifier:
|
|||||||
# poll_enabled 影响),供甲骨文侧 has_motion_in_range_local() 判断
|
# poll_enabled 影响),供甲骨文侧 has_motion_in_range_local() 判断
|
||||||
# "这段时间没收到运动事件"是真的没运动,还是推送链路已经挂了。
|
# "这段时间没收到运动事件"是真的没运动,还是推送链路已经挂了。
|
||||||
self.heartbeat_interval_sec = int(cfg.get('heartbeat_interval_sec', 300))
|
self.heartbeat_interval_sec = int(cfg.get('heartbeat_interval_sec', 300))
|
||||||
# 轮询路径(poll_enabled=true 时)关注的摄像头;Webhook 路径用摄像头名映射
|
# 轮询路径(poll_enabled=true 时)关注的摄像头
|
||||||
self.camera_ids = cfg.get('camera_ids', [2])
|
self.camera_ids = cfg.get('camera_ids', [2])
|
||||||
# 摄像头名 -> camera_id 静态映射(优先),启动时再从 SS 拉一份补全
|
|
||||||
self.camera_name_to_id = {
|
|
||||||
str(k): int(v) for k, v in (cfg.get('camera_name_to_id') or {}).items()
|
|
||||||
}
|
|
||||||
self._ss_name_to_id = {}
|
|
||||||
self._camera_loaded = False
|
|
||||||
self._base = f"http://{self.dsm_host}:{self.dsm_port}/webapi"
|
self._base = f"http://{self.dsm_host}:{self.dsm_port}/webapi"
|
||||||
self._sid = None
|
self._sid = None
|
||||||
self._running = False
|
self._running = False
|
||||||
@@ -160,134 +149,6 @@ class MotionNotifier:
|
|||||||
return events
|
return events
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 摄像头名 -> camera_id 映射(一次性从 SS 拉取,供 Webhook 使用,非轮询)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def refresh_camera_map(self):
|
|
||||||
"""登录 SS 拉取摄像头列表,构建 name->id 映射(启动时调用一次)。
|
|
||||||
|
|
||||||
失败时仅告警,不影响 Webhook 运行(配置兜底 camera_name_to_id 仍可生效)。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
sid = self._login()
|
|
||||||
if not sid:
|
|
||||||
logger.warning("摄像头映射刷新失败:SS 登录失败")
|
|
||||||
return
|
|
||||||
resp = requests.get(
|
|
||||||
f"{self._base}/entry.cgi",
|
|
||||||
params={"api": "SYNO.SurveillanceStation.Camera", "version": 1,
|
|
||||||
"method": "List", "_sid": sid},
|
|
||||||
timeout=self.timeout)
|
|
||||||
data = resp.json()
|
|
||||||
if not data.get('success'):
|
|
||||||
logger.warning(f"摄像头映射刷新失败:{data.get('error')}")
|
|
||||||
return
|
|
||||||
for c in (data.get('data') or {}).get('cameras', []):
|
|
||||||
name = c.get('name')
|
|
||||||
cid = c.get('id')
|
|
||||||
if name and cid is not None:
|
|
||||||
self._ss_name_to_id[str(name)] = int(cid)
|
|
||||||
self._camera_loaded = True
|
|
||||||
logger.info(f"摄像头映射已刷新:{self._ss_name_to_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"摄像头映射刷新异常:{e}")
|
|
||||||
|
|
||||||
def resolve_camera_id(self, device_name):
|
|
||||||
"""把 SS Webhook 的 %DEVICE_NAME%(摄像头名)解析为 camera_id(int|None)。"""
|
|
||||||
if not device_name:
|
|
||||||
return None
|
|
||||||
name = str(device_name).strip()
|
|
||||||
for mapping in (self.camera_name_to_id, self._ss_name_to_id):
|
|
||||||
if name in mapping:
|
|
||||||
return mapping[name]
|
|
||||||
low = name.lower()
|
|
||||||
for k, v in mapping.items():
|
|
||||||
if k.lower() == low:
|
|
||||||
return v
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_ss_time_to_epoch(s):
|
|
||||||
"""把 SS Webhook 的 %EVENT_TIME%(本地时间字符串)解析为 Unix epoch。
|
|
||||||
|
|
||||||
兼容 '2026-08-22T10:35:00' / '2026-08-22T10:35' / 带空格 / 带时区偏移。
|
|
||||||
无时区信息时按 NAS 本地时区(CST, +8)解释,得到绝对 epoch,
|
|
||||||
与甲骨文 ss_motion_events.start_time(同为绝对 epoch)一致。
|
|
||||||
"""
|
|
||||||
if not s:
|
|
||||||
return None
|
|
||||||
s = str(s).strip()
|
|
||||||
# 带 Z / +08:00 偏移:交给 fromisoformat
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(s.replace('Z', '+00:00'))
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
dt = dt.replace(tzinfo=datetime.now().astimezone().tzinfo)
|
|
||||||
return int(dt.timestamp())
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
# 无偏移的多种格式兜底
|
|
||||||
base = s.replace('T', ' ')
|
|
||||||
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M',
|
|
||||||
'%Y/%m/%d %H:%M:%S', '%Y/%m/%d %H:%M'):
|
|
||||||
try:
|
|
||||||
dt = datetime.strptime(base, fmt)
|
|
||||||
dt = dt.replace(tzinfo=datetime.now().astimezone().tzinfo)
|
|
||||||
return int(dt.timestamp())
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
logger.warning(f"无法解析 SS 时间:{s}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _synth_event_id(device_name, event_time, thumbnail_url):
|
|
||||||
"""合成稳定的 event_id(用于幂等去重)。
|
|
||||||
|
|
||||||
含 thumbnail_url 使同分钟内的多次真实事件可区分;SS 重试(相同 payload)
|
|
||||||
则哈希相同 -> 甲骨文 UNIQUE 约束幂等覆盖。
|
|
||||||
"""
|
|
||||||
raw = f"{device_name}|{event_time}|{thumbnail_url or ''}"
|
|
||||||
return int(hashlib.sha256(raw.encode('utf-8')).hexdigest()[:12], 16)
|
|
||||||
|
|
||||||
def build_event_from_webhook(self, raw):
|
|
||||||
"""把一条 SS Webhook 原始字段映射为甲骨文所需的归一化运动事件。
|
|
||||||
|
|
||||||
SS Webhook 不提供 event_id / camera_id 数字字段,此处:
|
|
||||||
- event_id : 由 device_name+event_time+thumbnail_url 合成(稳定、可去重)
|
|
||||||
- camera_id : 由 device_name 经摄像头映射解析(解析失败则 None)
|
|
||||||
- event_type : 运动固定 10
|
|
||||||
- start_time : EVENT_TIME 解析为 epoch
|
|
||||||
- duration : Webhook 无此字段,置 0
|
|
||||||
返回 dict 或 None(字段不足无法构造)。
|
|
||||||
"""
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
return None
|
|
||||||
device_name = (raw.get('device_name') or raw.get('camera') or
|
|
||||||
raw.get('device') or raw.get('cam_name'))
|
|
||||||
event_time = raw.get('event_time') or raw.get('time')
|
|
||||||
thumbnail_url = (raw.get('thumbnail_url') or raw.get('thumbnail') or
|
|
||||||
raw.get('thumb'))
|
|
||||||
if not device_name or not event_time:
|
|
||||||
logger.warning(f"Webhook 事件缺字段 device_name/event_time: {raw}")
|
|
||||||
return None
|
|
||||||
start_ts = self.parse_ss_time_to_epoch(event_time)
|
|
||||||
if start_ts is None:
|
|
||||||
# 解析失败:退化为当前时间,宁可误报也不漏报
|
|
||||||
start_ts = int(datetime.now(timezone.utc).timestamp())
|
|
||||||
logger.warning(f"Webhook 事件时间解析失败,退化为当前时间: {event_time}")
|
|
||||||
camera_id = self.resolve_camera_id(device_name)
|
|
||||||
if camera_id is None and not self._camera_loaded:
|
|
||||||
# 懒加载一次摄像头映射后重试
|
|
||||||
self.refresh_camera_map()
|
|
||||||
camera_id = self.resolve_camera_id(device_name)
|
|
||||||
return {
|
|
||||||
"event_id": self._synth_event_id(device_name, event_time, thumbnail_url),
|
|
||||||
"camera_id": camera_id,
|
|
||||||
"event_type": 10,
|
|
||||||
"start_time": start_ts,
|
|
||||||
"duration": 0,
|
|
||||||
"thumbnail_url": thumbnail_url,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 推送到甲骨文
|
# 推送到甲骨文
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ def get_sync():
|
|||||||
class OracleSync:
|
class OracleSync:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
cfg = load_config().get('oracle_sync', {})
|
cfg = load_config().get('oracle_sync', {})
|
||||||
self.base_url = cfg.get('base_url', 'http://129.146.203.203:5000').rstrip('/')
|
self.base_url = cfg.get('base_url', 'http://129.146.26.249:5000').rstrip('/')
|
||||||
self.token = cfg.get('token', '')
|
self.token = cfg.get('token', '')
|
||||||
self.interval_sec = int(cfg.get('interval_sec', 1800))
|
self.interval_sec = int(cfg.get('interval_sec', 1800))
|
||||||
self.timeout = int(cfg.get('timeout', 120))
|
self.timeout = int(cfg.get('timeout', 120))
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
"""
|
|
||||||
Static-App - 提供 Vue 前端构建产物(新架构 v3:fam-ui 不再单独起 Streamlit 进程)
|
|
||||||
|
|
||||||
fam-ui/dist/ 是本地 `npm run build` 出的静态文件,部署时整个目录拷到 NAS。
|
|
||||||
本模块只做两件事: 命中真实静态文件(如 /assets/xxx.js)直接下发;其余任何路径
|
|
||||||
(Vue Router history 模式的前端路由)一律回退到 index.html,由浏览器端路由接管。
|
|
||||||
|
|
||||||
必须最后注册(app.py 里排在 chat_bp/member_bp/img_bp/ui_bp 之后),否则这里的
|
|
||||||
通配路由会先于 /api/* 匹配,把 API 请求也吞成 index.html。
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
|
|
||||||
from flask import Blueprint, send_from_directory, abort
|
|
||||||
|
|
||||||
DIST_DIR = os.path.join(
|
|
||||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
|
||||||
'..', 'fam-ui', 'dist'
|
|
||||||
)
|
|
||||||
DIST_DIR = os.path.normpath(DIST_DIR)
|
|
||||||
|
|
||||||
static_bp = Blueprint('static_app', __name__)
|
|
||||||
|
|
||||||
|
|
||||||
@static_bp.route('/', defaults={'path': ''})
|
|
||||||
@static_bp.route('/<path:path>')
|
|
||||||
def spa(path):
|
|
||||||
if not os.path.isdir(DIST_DIR):
|
|
||||||
abort(404, "前端构建产物不存在,请先 npm run build 并部署 fam-ui/dist")
|
|
||||||
full = os.path.join(DIST_DIR, path)
|
|
||||||
if path and os.path.isfile(full):
|
|
||||||
return send_from_directory(DIST_DIR, path)
|
|
||||||
return send_from_directory(DIST_DIR, 'index.html')
|
|
||||||
@@ -11,7 +11,6 @@ def _cfg(**overrides):
|
|||||||
"dsm_port": 5000,
|
"dsm_port": 5000,
|
||||||
"dsm_account": "ericwyuan",
|
"dsm_account": "ericwyuan",
|
||||||
"dsm_password": "secret",
|
"dsm_password": "secret",
|
||||||
"camera_name_to_id": {"Generic_ONVIF-001": 2},
|
|
||||||
"oracle_base_url": "http://oracle.example:5000",
|
"oracle_base_url": "http://oracle.example:5000",
|
||||||
"oracle_token": "tok123",
|
"oracle_token": "tok123",
|
||||||
"heartbeat_interval_sec": 300,
|
"heartbeat_interval_sec": 300,
|
||||||
@@ -38,70 +37,6 @@ class _FakeResp:
|
|||||||
return self._payload
|
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 就该跑)
|
# 心跳(跟轮询无关,enabled=true 就该跑)
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user