Author SHA1 Message Date
ila 5b42f58f23 Merge pull request '[T-015] 回填 Gitea Issue 映射' (#52) from governance/T-015-map into main
Harness governance / validate (push) Has been cancelled
2026-08-10 23:37:22 +08:00
QiuSW 8b11b39093 docs(tasks): map T-015 to issue 51
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-10 23:37:08 +08:00
ila 31b08454f9 Merge pull request '[T-015] 定义 Bell 不可变事件存储任务' (#50) from bootstrap/T-015 into main
Harness governance / validate (push) Has been cancelled
2026-08-10 23:36:19 +08:00
QiuSW cefa6275bf docs(tasks): define T-015 Bell immutable events
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-10 23:36:01 +08:00
ila 5ab212cb5c Merge pull request '[T-014] 建立 Sense 本地 16 路批量收敛与容量基线' (#49)
Harness governance / validate (push) Has been cancelled
Closes #47
2026-08-10 23:18:52 +08:00
QiuSW b9e21a95d1 docs(task): record T-014 16-stream baseline
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-10 23:16:12 +08:00
QiuSW d029067aa7 fix(lab): refresh first T-014 batch ETags [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-10 22:39:13 +08:00
QiuSW d355f3d069 fix(lab): report frozen Sense Go toolchain [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-10 22:35:25 +08:00
QiuSW 71e5e6e599 fix(lab): schedule all T-014 stability samples [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-08 11:18:46 +08:00
QiuSW c3cfaff614 fix(lab): release T-014 process handles before cleanup [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-08 10:45:23 +08:00
QiuSW 707ca8e508 fix(lab): avoid PostgreSQL pipeline deadlock [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-08 10:11:36 +08:00
QiuSW 403fd6e554 fix(lab): bound T-014 process cleanup [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-08 09:52:28 +08:00
QiuSW 5d222436f1 feat(lab): add 16-stream capacity harness [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-08 09:10:40 +08:00
QiuSW 3af6a16740 chore(task): claim T-014
Harness governance / validate (push) Has been cancelled
2026-08-08 08:56:00 +08:00
ila 8ebb8ced49 Merge pull request '[T-014] 回填 Gitea Issue 映射' (#48) from governance/T-014-map into main
Harness governance / validate (push) Has been cancelled
2026-08-08 08:54:35 +08:00
QiuSW 2b4ec7053b docs(task): map T-014 to Gitea issue 47
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-08 08:54:17 +08:00
ila 8f4717a322 Merge pull request '[T-014] 冻结本地 16 路批量收敛与容量基线规格' (#46) from governance/T-014-define into main
Harness governance / validate (push) Has been cancelled
2026-08-08 08:53:28 +08:00
QiuSW 951508b8b4 docs(task): define T-014 capacity baseline
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-08 08:52:59 +08:00
ila a8f50cf79f Merge pull request '[T-012] 补齐 Sense 对账安全闸、孤儿受控处置与多实例可观测性' (#45) from agent/codex/T-012 into main
Harness governance / validate (push) Has been cancelled
2026-08-07 23:01:18 +08:00
QiuSW 12857fdf32 feat(sense): add reconciliation safety controls [T-012]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 23:00:03 +08:00
QiuSW 677ed732f7 chore(task): claim T-012
Harness governance / validate (push) Has been cancelled
2026-08-07 22:28:16 +08:00
ila 25357723a0 Merge pull request '[T-012] 回填 Gitea Issue 映射' (#44) from governance/T-012-map into main
Harness governance / validate (push) Has been cancelled
2026-08-07 22:26:01 +08:00
QiuSW b7a4a0faf8 docs(task): map T-012 to Gitea issue 43
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 22:25:42 +08:00
ila 872b6c9a6c Merge pull request '[T-012] 冻结对账安全闸与多实例可观测性规格' (#42) from governance/T-012-define into main
Harness governance / validate (push) Has been cancelled
2026-08-07 22:24:43 +08:00
QiuSW 6f65121987 docs(task): define T-012 reconcile safety work
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 22:24:20 +08:00
ila 6757ef2c05 Merge pull request '[T-011] 实现 Sense Control API v1 与 PostgreSQL 一致性边界' (#41) from agent/codex/T-011 into main
Harness governance / validate (push) Has been cancelled
2026-08-07 20:57:18 +08:00
QiuSW a0d239811f feat(sense): implement Control API v1 [T-011]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 20:54:41 +08:00
QiuSW 0d3d7a22ed chore(task): renew T-011 write paths
Harness governance / validate (push) Has been cancelled
2026-08-07 20:39:59 +08:00
QiuSW 20b06884ad chore(task): claim T-011
Harness governance / validate (push) Has been cancelled
2026-08-07 20:14:37 +08:00
ila 6402d4384d Merge pull request '[T-011] 回填 Gitea Issue 映射' (#40) from governance/T-011-map into main
Harness governance / validate (push) Has been cancelled
2026-08-07 20:12:34 +08:00
QiuSW e8121a6a8e docs(task): map T-011 to Gitea issue 39
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 20:12:21 +08:00
ila 907d50d343 Merge pull request '[T-011] 定义 Sense Control API v1 实现任务' (#38) from governance/T-011-define into main
Harness governance / validate (push) Has been cancelled
2026-08-07 20:11:38 +08:00
QiuSW 3c1cce9d5f docs(task): define T-011 control API implementation
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 20:11:14 +08:00
ila 31ac2c40ac [T-010] 建立 Area 准入投影与设备操作审计 Outbox 基础
Harness governance / validate (push) Has been cancelled
Closes #35
2026-08-07 18:24:47 +08:00
QiuSW 36786723e3 feat(store): add Area admission and audit outbox [T-010]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 18:23:24 +08:00
QiuSW 8abff0a566 chore(task): claim T-010
Harness governance / validate (push) Has been cancelled
2026-08-07 18:08:20 +08:00
ila 0b40cf16c3 Governance: map T-010 to Gitea issue 35
Harness governance / validate (push) Has been cancelled
Complete the task-to-Issue mapping before claim.
2026-08-07 18:06:49 +08:00
QiuSW 997e515152 docs(task): map T-010 to Gitea issue 35
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 18:06:29 +08:00
ila c188c4c9e0 Governance: define T-010 task
Harness governance / validate (push) Has been cancelled
Add T-010 specification before Issue creation.
2026-08-07 18:05:42 +08:00
QiuSW a9323bc9de docs(task): define T-010 area audit foundation
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 18:05:20 +08:00
ila b6a752c014 [T-009] 建立 PostgreSQL 双 Schema 与 Bell 配额投影基础
Harness governance / validate (push) Has been cancelled
Closes #31
2026-08-07 17:54:45 +08:00
QiuSW 2ff7ff5618 feat(store): add PostgreSQL foundation [T-009]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 17:52:50 +08:00
QiuSW 04231d006a chore(task): claim T-009
Harness governance / validate (push) Has been cancelled
2026-08-07 17:32:48 +08:00
ila 8a4514076e Merge pull request 'Governance: [T-009] 回填 Gitea Issue 映射' (#32) from governance/T-009-map into main
Harness governance / validate (push) Has been cancelled
2026-08-07 17:30:45 +08:00
QiuSW d517d517d9 docs(task): map T-009 to Gitea issue 31
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 17:30:30 +08:00
ila 38c12bb5b7 Merge pull request 'Governance: [T-009] 冻结 PostgreSQL 基础任务规格' (#30) from governance/T-009-spec into main
Harness governance / validate (push) Has been cancelled
2026-08-07 17:29:46 +08:00
QiuSW d332797fcb docs(task): define T-009 PostgreSQL foundation
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 17:29:16 +08:00
ila 14d29a47d1 Merge pull request '[T-008] 冻结 Sense 设备管理 API 与 Bell 配额只读投影契约' (#29) from agent/codex/T-008 into main
Harness governance / validate (push) Has been cancelled
2026-08-07 17:16:22 +08:00
QiuSW b70df147ea docs(contract): freeze Sense control API v1 [T-008]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 17:12:15 +08:00
QiuSW 1c63143106 chore(task): claim T-008
Harness governance / validate (push) Has been cancelled
2026-08-07 16:57:28 +08:00
ila cc47e14638 Merge pull request 'Governance: [T-008] 回填 Gitea Issue 映射' (#28) from governance/T-008-map into main
Harness governance / validate (push) Has been cancelled
2026-08-07 16:56:12 +08:00
QiuSW 461b823570 docs(task): map T-008 to Gitea issue 27
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 16:55:59 +08:00
ila 885dd0143e Merge pull request 'Governance: [T-008] 冻结控制面契约任务规格' (#26) from governance/T-008-spec into main
Harness governance / validate (push) Has been cancelled
2026-08-07 16:55:09 +08:00
QiuSW 11e675d45c docs(task): define T-008 control contracts
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 16:54:49 +08:00
ila 5db37c7142 Merge pull request '[T-006] 完成 Sense 单实机与五路混合源集成验收' (#25) from agent/codex/T-006 into main
Harness governance / validate (push) Has been cancelled
2026-08-07 16:38:41 +08:00
QiuSW 9567838045 feat(sense): complete T-006 five-stream integration
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 16:33:35 +08:00
QiuSW 08692e33e9 chore(task): claim T-006
Harness governance / validate (push) Has been cancelled
2026-08-07 15:16:10 +08:00
ila 94ae2f0048 Merge pull request 'T-001: 完成指定海康摄像头准入验证' (#24) from agent/codex/T-001 into main
Harness governance / validate (push) Has been cancelled
2026-08-05 17:41:41 +08:00
QiuSW c9251daadf docs(camera): approve single Hikvision baseline
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-05 17:38:54 +08:00
ila 56c07427c8 Merge pull request 'Governance: 固化单海康实机与合成流开发策略' (#23) from governance/single-camera-development into main
Harness governance / validate (push) Has been cancelled
2026-08-04 17:27:09 +08:00
QiuSW fd16f77b95 docs(requirements): adopt single-camera development strategy
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 17:25:33 +08:00
ila 155c927a62 Merge pull request 'Governance: 重构 T-001 为指定型号准入验证' (#21) from governance/T-001-single-model into main
Harness governance / validate (push) Has been cancelled
2026-08-04 16:04:59 +08:00
QiuSW 3db1cf94e3 docs(requirements): scope T-001 to one camera model
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 16:04:15 +08:00
ila aa62147702 Merge pull request '[T-003] ?? Sense M1 ???????' (#20) from agent/codex/T-003 into main
Harness governance / validate (push) Has been cancelled
2026-08-04 15:50:14 +08:00
QiuSW 5208891e02 feat(sense): establish M1 offline intake skeleton
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 15:45:50 +08:00
QiuSW bafd8d983e docs(tasks): claim T-003
Harness governance / validate (push) Has been cancelled
2026-08-04 15:12:01 +08:00
ila e28070dd03 Governance: audit merged PRs after branch cleanup
Harness governance / validate (push) Has been cancelled
兼容 Gitea 已合并分支清理后的不可变 PR 引用。
2026-08-04 15:08:04 +08:00
QiuSW ef0e76b201 fix(governance): audit merged PRs after branch cleanup
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 15:07:47 +08:00
ila 43c5c18eec Governance: add non-claimable waiting status
Harness governance / validate (push) Has been cancelled
为依赖/外部条件等待任务增加无 claim 状态。
2026-08-04 15:05:52 +08:00
QiuSW f85dbecf93 chore(governance): add waiting task status
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 15:05:33 +08:00
ila 4be15f2452 Map T-006 to issue 16
Harness governance / validate (push) Has been cancelled
完成真实设备集成任务映射。
2026-08-04 15:01:05 +08:00
QiuSW 06de7d0399 docs(tasks): map T-006 to issue 16
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 15:00:47 +08:00
ila 968de2783c Bootstrap T-006 real-camera integration gate
Harness governance / validate (push) Has been cancelled
建立 M1 真实设备集成验收规格。
2026-08-04 15:00:13 +08:00
QiuSW e2e75694cd docs(tasks): define T-006 camera integration gate
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 14:59:54 +08:00
ila a800068b8a Replan T-003 as hardware-independent Sense skeleton
Harness governance / validate (push) Has been cancelled
拆分无实机骨架与真实设备集成门禁。
2026-08-04 14:57:51 +08:00
QiuSW bb1010c09f docs(tasks): split T-003 from hardware validation
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 14:57:30 +08:00
ila 58a0056b1b Record T-001 hardware validation deferral
Harness governance / validate (push) Has been cancelled
保留 TODO 规格,记录暂缓与恢复条件。
2026-08-04 14:55:32 +08:00
QiuSW fca32413fa docs(tasks): keep deferred T-001 unclaimed
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 14:55:09 +08:00
QiuSW 6b18857305 docs(tasks): defer T-001 hardware validation
Harness governance / validate (push) Has been cancelled
2026-08-04 14:54:32 +08:00
ila d5cb2349cf Merge pull request #12: T-005 评审原稿归档
Harness governance / validate (push) Has been cancelled
保留 T-004 定稿,归档原始评审材料并补录缺失文档。
2026-08-04 14:47:08 +08:00
QiuSW c7d0ce3ed9 docs(raw): archive prototype review sources
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 14:46:12 +08:00
QiuSW ec9ae76a0d docs(tasks): claim T-005
Harness governance / validate (push) Has been cancelled
2026-08-04 14:44:27 +08:00
ila 697e652e9b Map T-005 to issue 10
Harness governance / validate (push) Has been cancelled
完成任务与工单映射。
2026-08-04 14:43:01 +08:00
QiuSW 5ed7397ff0 docs(tasks): map T-005 to issue 10
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 14:42:35 +08:00
ila ac92d04d71 Bootstrap T-005 review archive task
Harness governance / validate (push) Has been cancelled
建立 T-005 版本化任务规格。
2026-08-04 14:41:49 +08:00
QiuSW edde69ac8d docs(tasks): define T-005 review archive
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 14:41:24 +08:00
ila de2f1027ec Merge pull request #8: T-004 Sense 与 Bell 交互原型
Harness governance / validate (push) Has been cancelled
人工验收通过,完成 T-004。
2026-08-04 14:22:18 +08:00
QiuSW 547232b7b8 docs(tasks): complete T-004 prototype acceptance
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 14:20:37 +08:00
QiuSW 8115f584dd docs(design): unify Bell roster and escalation targets
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 14:14:06 +08:00
QiuSW e96cc2fbb5 docs(design): close Bell prototype review gaps
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 11:49:18 +08:00
QiuSW e0b0bfafeb docs(design): close Sense second-review gaps
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 11:08:41 +08:00
QiuSW e18a1ea83b docs(design): apply Sense prototype review
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 10:05:56 +08:00
ila d36b785f67 docs(task): expand T-004 review scope
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 09:37:48 +08:00
ila 122cde4938 docs(task): preserve complete T-004 revision record
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 09:07:26 +08:00
ila 9a82b17b10 docs(design): restore complete Bell prototype
Harness governance / validate (push) Has been cancelled
2026-08-04 09:07:23 +08:00
ila cce38003cb docs(design): restore complete Sense prototype
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 09:07:18 +08:00
ila 618174cbf2 docs(task): record product review revision
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 09:04:58 +08:00
ila 5251199205 docs(design): update Bell workflow
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 09:04:52 +08:00
ila 2715e557a9 docs(design): update Sense workflow
Harness governance / validate (push) Has been cancelled
2026-08-04 09:04:50 +08:00
QiuSW f43a4efa80 docs(design): add Sense and Bell prototypes
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 00:23:55 +08:00
QiuSW b643e42535 docs(tasks): claim T-004
Harness governance / validate (push) Has been cancelled
2026-08-04 00:02:20 +08:00
ila 32b9859a40 Map prototype task T-004 to issue 6
Harness governance / validate (push) Has been cancelled
Complete the task-to-issue mapping before claim.
2026-08-04 00:00:29 +08:00
QiuSW 7ee33d27f7 docs(tasks): map T-004 to issue 6
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 00:00:08 +08:00
ila 6362d35868 Bootstrap T-004 prototype task
Harness governance / validate (push) Has been cancelled
Add the versioned task definition before Gitea issue mapping.
2026-08-03 23:59:20 +08:00
QiuSW 3a81cc44b4 docs(tasks): define T-004 UI prototypes
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-03 23:59:01 +08:00
ila 633a906778 [T-002] 关闭架构影响型需求开放问题
Harness governance / validate (push) Has been cancelled
关闭 Q1、Q2、Q4–Q12,并保留 M3/M5 下游合规上线门禁。
2026-08-03 23:50:00 +08:00
142 changed files with 36825 additions and 164 deletions
+3
View File
@@ -6,6 +6,9 @@ agent_sessions.txt
gitea.env
gitea.env.*
!gitea.env.example
ip_camera.env
ip_camera.env.*
!ip_camera.env.example
# 常见构建与测试缓存
__pycache__/
+4
View File
@@ -0,0 +1,4 @@
/bin/
/data/
*.db-shm
*.db-wal
-1
View File
@@ -1 +0,0 @@
+156
View File
@@ -0,0 +1,156 @@
# Sense M1/M2 接入骨架
本目录是 YoVision Sense 的 M1/M2 接入骨架。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009~T-012 增加 PostgreSQL 双 schema、Area 准入、本地审计 Outbox、Control API v1、多实例调和 fencing 和孤儿受控处置。默认关闭真实 ONVIF 与公共业务路由;T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。
## 常用命令
```powershell
cd Sense
go mod download
go generate ./internal/mtx ./internal/controlapi
go test ./...
go vet ./...
go build -o bin/sense-api.exe ./cmd/sense-api
go run ./cmd/sense-api
```
Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`,SQLite 默认写入 `Sense/data/sense.db`,MediaMTX 控制 API 默认是 `http://127.0.0.1:9997`。默认运行暴露 `/healthz`、`/readyz` 和不含租户/设备标签的 `/metrics`;只有显式选择 PostgreSQL 并完成安全配置后才注册 7 个 `/api/v1` Control API 路由。
常用环境变量:
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `SENSE_HTTP_ADDR` | `127.0.0.1:8080` | HTTP 监听地址 |
| `SENSE_ALLOW_NON_LOOPBACK` | `false` | 显式允许监听非回环地址;只应在可信网络及外部认证/防火墙就绪后开启 |
| `SENSE_DB_DRIVER` | `sqlite` | `sqlite` 或 `postgres`;生产切换必须显式选择 `postgres` |
| `SENSE_DB_DSN` | `file:data/sense.db` | 所选 driver 的私有 DSN;不得写入日志、文档或仓库,PostgreSQL 密码优先由 `PGPASSFILE`/环境密钥提供 |
| `SENSE_MEDIAMTX_URL` | `http://127.0.0.1:9997` | MediaMTX 控制 API;不得包含 userinfo |
| `SENSE_RECONCILE_INTERVAL` | `5s` | 对账周期 |
| `SENSE_RECONCILE_LEASE_DURATION` | `30s` | PostgreSQL due-row 租期;最大 5 分钟 |
| `SENSE_RECONCILE_OPERATION_TIMEOUT` | `20s` | 单项 ONVIF/MediaMTX deadline;必须严格短于租期 |
| `SENSE_PROBE_INTERVAL` | `10s` | path 探活周期 |
| `SENSE_INSTANCE_ID` | 随进程随机生成 | 最多 64 位低基数字符串;多实例部署建议显式注入唯一实例 ID |
| `SENSE_METRICS_ENABLED` | `true` | 是否注册低基数 Prometheus 文本 `/metrics` |
| `SENSE_ORPHAN_SCAN_ENABLED` | PostgreSQL 为 `true`,SQLite 为 `false` | 周期执行只读 MediaMTX Path 差异报告;从不自动删除 |
| `SENSE_ORPHAN_SCAN_INTERVAL` | `1m` | 孤儿只读扫描周期,最短 10 秒 |
| `SENSE_ONVIF_MODE` | `disabled` | `disabled` 或 `standard`;默认不访问真实摄像头 |
| `SENSE_ONVIF_RTSP_REWRITE_HOST` | 空 | NAT 或故障代理场景下重写 ONVIF 返回的 RTSP 主机 |
| `SENSE_ONVIF_RTSP_REWRITE_PORT` | `0` | 非零时重写 ONVIF 返回的 RTSP 端口 |
| `SENSE_ONVIF_RTSP_STRIP_QUERY` | `false` | 仅在已验证设备返回不可用查询串时显式移除;默认保留标准 URI 语义 |
| `SENSE_CONTROL_API_ENABLED` | `false` | 显式开启 Control API v1;只允许与 PostgreSQL 一起使用 |
| `SENSE_CONTROL_AUTH_MODE` | `static-sha256` | 首版外部摘要注册表适配器;token 格式不属于公共 API 契约 |
| `SENSE_CONTROL_AUTH_FILE` | 空 | 仓库外绝对路径;version 1 JSON 只保存 token SHA-256、主体、tenant、Site scope 和权限 |
| `SENSE_CONTROL_CURSOR_KEY_FILE` | 空 | 仓库外绝对路径;内容为至少 32 字节随机值的无填充 base64url |
| `SENSE_CONTROL_ALLOW_INSECURE_HTTP` | `false` | Control API 非回环明文监听的独立风险接受;正常部署应保持回环并在受控代理终止 TLS |
设备台账只保存 `env://<key>` 凭据引用。真实适配器从进程环境读取以下变量,不把秘密写入 SQLite、日志或 MediaMTX 错误:
```text
SENSE_CREDENTIAL_<KEY>_ONVIF_USERNAME
SENSE_CREDENTIAL_<KEY>_ONVIF_PASSWORD
SENSE_CREDENTIAL_<KEY>_RTSP_USERNAME
SENSE_CREDENTIAL_<KEY>_RTSP_PASSWORD
```
`cmd/sense-lab` 是回环实验室播种与脱敏收敛查询工具,不是已冻结的公共设备管理 API。`cmd/rtsp-fault-proxy` 只用于 T-006 控制真实上游网络路径故障。
MediaMTX `v1.19.3` 应作为独立二进制启动并只在可信网络开放 API。获取与 SHA-256 校验值见 `docs/03-tech-stack.md`。生成客户端使用固定版本工具和 vendored 官方 OpenAPI;`internal/mtx/generated/client.gen.go` 不可手改。
## T-009~T-012 PostgreSQL 17.10
初始化与增量 SQL 位于 `deploy/postgres/`,由高权限部署步骤按文件名前缀执行;Sense 进程不会自动创建角色、schema 或 Bell 对象。`bell_app` 拥有 Site/Area、配额、`capture_policy` 及两个版本化视图,`sense_app` 只能读取两个视图,不能读取或写入 Bell 源表。T-011 的 v4 schema 增加资源版本、24 小时幂等收据和 batch operation;T-012 的 v5 schema 增加数据库时钟租约、Path 历史归属及脱敏孤儿报告/处置结果。表中不保存 MediaMTX source URI。应用登录角色和密码由部署环境创建,不进入仓库。
PostgreSQL 新建设备必须携带匹配 tenant/Site 的 `area_id`。具有 `video_capture` 能力的设备在创建、移动 Area 和从 disabled 切到 enabled 时执行 Area 准入;`non_imaging_only` 拒绝成像设备但允许非成像设备。投影缺失、非法或版本回退只拒绝新变更,不关闭已有流。创建、配置修改和期望态受理都与对应脱敏 Outbox 事实同事务;停用后调和器只删除该设备的精确 MediaMTX path 并收敛为 offline,不枚举未知 path。Bell relay 尚未实现。
Windows 本机集成测试从仓库根目录执行:
```powershell
./scripts/test_postgres.ps1 -PgRoot D:\pgsql17
```
脚本要求冻结的 PostgreSQL `17.10`,使用 `initdb` 创建临时 trust 集群并只监听随机回环端口,重放 migration、执行权限断言和 PostgreSQL repository 测试后停止并清理。它会核对现有 `5432` listener 前后未变化,不读取或修改 `D:\pgsql17\data`。生产安装和恢复边界见 `deploy/postgres/README.md`。
选择 PostgreSQL 运行前,管理员必须已经安装 migration,并私下设置无密码回显的连接环境:
```powershell
$env:SENSE_DB_DRIVER = 'postgres'
$env:SENSE_DB_DSN = '由部署环境私下设置'
go run ./cmd/sense-api
```
PostgreSQL 启动会检查 Sense v5 migration、当前角色对两个 Bell 投影视图和本地控制/对账表的最小权限;权限过宽、视图不可读或 schema 未安装时初始化失败。默认 SQLite 路径和 `cmd/sense-lab` 保持不变,但 SQLite 不实现生产 Area/Outbox、多实例租约或孤儿处置语义,Control API 与孤儿扫描在 SQLite 下不会启动。
### 孤儿报告与受控处置
`sense-api` 的 PostgreSQL 模式默认每分钟只读枚举一次 MediaMTX 配置 Path,并持久化汇总。`unowned` 表示没有 Sense 历史归属证据,永远不会由 Sense 删除;只有 `owned_stale` 可以进入处置候选。需要人工处置时从 `Sense/` 执行:
```powershell
go run ./cmd/sense-orphan -mode report
go run ./cmd/sense-orphan -mode apply -scan-id scan_... -actor operator-id -confirm "DELETE scan_..."
```
`report` 输出不含 source URI 的 JSON 摘要。`apply` 会再次读取数据库和 MediaMTX,只处理仍属于原快照的 `owned_stale`,并要求快照不超过 15 分钟、候选不超过 128 且候选占当前全部配置 Path 不超过 10%;任何门禁失败均为零删除,没有 `force`。完整流程与恢复方法见 [`../docs/runbooks/sense-reconciliation.md`](../docs/runbooks/sense-reconciliation.md)。
### 开启 Control API
复制 [`api/control-auth.example.json`](api/control-auth.example.json) 到仓库外受限目录并替换占位项。`token_sha256` 是至少 128 bit 随机 Bearer token 的 64 位小写 SHA-256,不是 token 明文;`site_ids` 支持精确 ID 或 `"*"`,权限只接受 `sense.devices.read`、`sense.devices.write`。注册表在启动时读取,轮换后需要受控重启。
另在仓库外生成 cursor key。PowerShell 示例只把 key 写入指定秘密文件,不把值打印到日志:
```powershell
$cursorBytes = [byte[]]::new(32)
[Security.Cryptography.RandomNumberGenerator]::Fill($cursorBytes)
$cursorKey = [Convert]::ToBase64String($cursorBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
Set-Content -LiteralPath 'D:\private\sense-cursor.key' -Value $cursorKey -NoNewline
```
完成 PostgreSQL migration、专用登录角色和外部文件权限后,以私有环境开启:
```powershell
$env:SENSE_DB_DRIVER = 'postgres'
$env:SENSE_DB_DSN = '由部署环境私下设置'
$env:SENSE_CONTROL_API_ENABLED = 'true'
$env:SENSE_CONTROL_AUTH_FILE = 'D:\private\sense-auth.json'
$env:SENSE_CONTROL_CURSOR_KEY_FILE = 'D:\private\sense-cursor.key'
go run ./cmd/sense-api
```
业务响应使用 `Cache-Control: no-store`;ETag 是写并发令牌,cursor 与认证 tenant/Site/筛选绑定。静态摘要文件只是首版私有部署适配器;公网/TLS、Bell 会话、JWT/OIDC 与热加载需后续任务,不能靠设置 `SENSE_CONTROL_ALLOW_INSECURE_HTTP=true` 冒充完成。
Windows 本地准备 MediaMTX(从仓库根目录执行):
```powershell
$asset = "mediamtx_v1.19.3_windows_amd64.zip"
Invoke-WebRequest "https://github.com/bluenviron/mediamtx/releases/download/v1.19.3/$asset" -OutFile "$env:TEMP\$asset"
if ((Get-FileHash "$env:TEMP\$asset" -Algorithm SHA256).Hash.ToLowerInvariant() -ne "5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe") { throw "MediaMTX checksum mismatch" }
Expand-Archive "$env:TEMP\$asset" -DestinationPath "$env:TEMP\yovision-mediamtx-v1.19.3" -Force
& "$env:TEMP\yovision-mediamtx-v1.19.3\mediamtx.exe" "Sense\deploy\mediamtx.yml"
```
Linux amd64 使用同版 `mediamtx_v1.19.3_linux_amd64.tar.gz`,SHA-256 为 `a7ba21268fccda3ebc43fdad76b87fddb85ce77e725b5cb637bca724b5394fbe`。不要把下载的二进制或摄像头凭据提交到仓库。
## T-006 Windows 集成验证
脚本会启动两套独立 MediaMTX、4 个独立 FFmpeg publisher、真实摄像头网络故障代理和 Sense,在临时目录播种 5 条期望态,执行四类恢复后再连续观察 30 分钟。脚本只输出脱敏计数与时间,不保存视频:
```powershell
./Sense/scripts/t006-integration.ps1 -CameraEnv D:\path\to\ip_camera.env
```
`ip_camera.env` 必须保持在 Git 忽略范围内。调试时可把 `-ObservationMinutes` 降为 1;正式 T-006 证据必须使用默认 30 分钟,且最终 `maximum_unconverged`、`final_unconverged` 都为 0。
## T-014 本地 16 路容量基线
T-014 不访问摄像头或客户网络。脚本使用隔离 PostgreSQL v5、真实 Control API、两套 MediaMTX 和 16 个独立的 FFmpeg `-c copy` 合成 publisher,验证 17 路配额拒绝、三轮批量启停、`16 → 0 → 16` Path 收敛、固定四路故障隔离/恢复,以及 30 分钟稳定性和资源观测。
从仓库根目录执行预检、短窗口调试和正式验收:
```powershell
./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -PreflightOnly
./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -ObservationMinutes 1 -OutputPath (Join-Path $env:TEMP 'yovision-t014-smoke.json')
./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -OutputPath (Join-Path $env:TEMP 'yovision-t014-formal.json')
```
只有默认不少于 30 分钟且输出 `formal_eligible=true` 的单次完整运行可作正式证据;调试窗口固定标记为 false。脚本要求 PostgreSQL 17.10、MediaMTX v1.19.3、FFmpeg 8.1.2 和 `Sense/` 模块的 Go 1.26.5,只使用随机回环端口,运行后清理临时媒体、二进制、PGDATA 和秘密文件。结果 JSON 不含 token、DSN、端口、设备 ID 或流地址。
2026-08-10 正式结果为 `1800.1 s / 180` 个 10 秒样本,最大/最终 `unconverged=0`、最终在线 Path 16、帧错误 0;完整版本、资源数据、失败记录和适用边界见 [`../docs/research/sense-16-stream-capacity.md`](../docs/research/sense-16-stream-capacity.md)。该结果只是本机低码率合成负载的软件基线,不代表真实 16 摄像头、客户网络、录像、AI/GPU、64/128 路或生产 SLA。
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"principals": [
{
"token_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"subject_id": "replace-with-service-id",
"actor_type": "service",
"tenant_id": "tenant-demo",
"site_ids": ["site-demo"],
"permissions": ["sense.devices.read", "sense.devices.write"]
}
]
}
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"log/slog"
"net"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
func main() {
listenAddress := flag.String("listen", "127.0.0.1:10554", "local listen address")
upstreamAddress := flag.String("upstream", "", "upstream host:port")
flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if *upstreamAddress == "" {
logger.Error("upstream is required")
os.Exit(2)
}
if _, _, err := net.SplitHostPort(*upstreamAddress); err != nil {
logger.Error("upstream must be host:port")
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := serve(ctx, *listenAddress, *upstreamAddress); err != nil {
logger.Error("RTSP fault proxy stopped", "error", err)
os.Exit(1)
}
}
func serve(ctx context.Context, listenAddress, upstreamAddress string) error {
listener, err := net.Listen("tcp", listenAddress)
if err != nil {
return fmt.Errorf("listen: %w", err)
}
defer listener.Close()
go func() {
<-ctx.Done()
_ = listener.Close()
}()
var connections sync.WaitGroup
defer connections.Wait()
for {
client, acceptErr := listener.Accept()
if acceptErr != nil {
if ctx.Err() != nil {
return nil
}
return fmt.Errorf("accept: %w", acceptErr)
}
connections.Add(1)
go func() {
defer connections.Done()
proxy(client, upstreamAddress)
}()
}
}
func proxy(client net.Conn, upstreamAddress string) {
defer client.Close()
upstream, err := net.DialTimeout("tcp", upstreamAddress, 5*time.Second)
if err != nil {
return
}
defer upstream.Close()
done := make(chan struct{}, 2)
copyOneWay := func(destination, source net.Conn) {
_, _ = io.Copy(destination, source)
done <- struct{}{}
}
go copyOneWay(upstream, client)
go copyOneWay(client, upstream)
<-done
}
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"yovision/sense/internal/auth"
"yovision/sense/internal/config"
"yovision/sense/internal/controlapi"
"yovision/sense/internal/metrics"
"yovision/sense/internal/mtx"
"yovision/sense/internal/onvif"
"yovision/sense/internal/orphan"
"yovision/sense/internal/probe"
"yovision/sense/internal/reconcile"
"yovision/sense/internal/store"
)
var version = "dev"
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := run(logger); err != nil {
logger.Error("Sense stopped", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load configuration: %w", err)
}
instanceID := cfg.InstanceID
if instanceID == "" {
instanceID, err = metrics.GenerateInstanceID()
if err != nil {
return err
}
}
registry := metrics.New(instanceID, version)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
repository, err := store.OpenRepository(ctx, cfg.DatabaseDriver, cfg.DatabaseDSN)
if err != nil {
return err
}
defer repository.Close()
var controlHandler http.Handler
if cfg.ControlAPIEnabled {
controlStore, ok := repository.(store.ControlRepository)
if !ok {
return errors.New("selected repository does not support Sense Control API")
}
authenticator, err := auth.LoadStaticSHA256(cfg.ControlAuthFile)
if err != nil {
return err
}
cursors, err := controlapi.LoadCursorCodec(cfg.ControlCursorKeyFile)
if err != nil {
return err
}
controlHandler = controlapi.NewHTTPHandler(controlStore, authenticator, cursors)
}
mediaClient, err := mtx.NewClient(cfg.MediaMTXURL, nil)
if err != nil {
return err
}
credentials := onvif.EnvCredentials{}
var cameraAdapter onvif.Adapter = onvif.UnavailableAdapter{}
if cfg.ONVIFMode == "standard" {
cameraAdapter = onvif.NewHTTPAdapter(credentials, nil, onvif.HTTPOptions{
RTSPRewriteHost: cfg.RTSPRewriteHost,
RTSPRewritePort: cfg.RTSPRewritePort,
StripRTSPQuery: cfg.RTSPStripQuery,
})
}
discovery := onvif.NewRouter(cameraAdapter, credentials)
reconciler := reconcile.NewWithOptions(repository, discovery, mediaClient, reconcile.Options{
InstanceID: instanceID, LeaseDuration: cfg.ReconcileLeaseDuration,
OperationTimeout: cfg.ReconcileOperationTimeout, Metrics: registry,
})
checker := probe.New(repository, mediaClient)
var orphanScanner *orphan.Manager
if cfg.OrphanScanEnabled {
orphanStore, ok := repository.(store.OrphanRepository)
if !ok {
return errors.New("selected repository does not support orphan scanning")
}
orphanScanner = orphan.New(orphanStore, mediaClient, instanceID, registry)
}
report := func(err error) {
// Domain and MediaMTX errors intentionally omit stream URIs and credentials.
logger.Warn("background convergence error", "error", err)
}
var background sync.WaitGroup
startBackground := func(run func()) {
background.Add(1)
go func() {
defer background.Done()
run()
}()
}
startBackground(func() { reconciler.Run(ctx, cfg.ReconcileInterval, report) })
startBackground(func() { checker.Run(ctx, cfg.ProbeInterval, report) })
if orphanScanner != nil {
startBackground(func() { orphanScanner.Run(ctx, cfg.OrphanScanInterval, report) })
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("GET /readyz", func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write([]byte(`{"status":"ready"}`))
})
if cfg.MetricsEnabled {
mux.Handle("GET /metrics", registry.Handler())
}
if cfg.ControlAPIEnabled {
mux.Handle("/api/v1/", controlHandler)
}
server := &http.Server{
Addr: cfg.HTTPAddress, Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
serverErrors := make(chan error, 1)
go func() {
logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version,
"instance_id", instanceID,
"control_api_enabled", cfg.ControlAPIEnabled)
serverErrors <- server.ListenAndServe()
}()
select {
case <-ctx.Done():
case serverErr := <-serverErrors:
if !errors.Is(serverErr, http.ErrServerClosed) {
stop()
background.Wait()
return fmt.Errorf("serve HTTP: %w", serverErr)
}
}
stop()
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
shutdownErr := server.Shutdown(shutdownContext)
background.Wait()
if shutdownErr != nil {
return fmt.Errorf("shutdown HTTP server: %w", shutdownErr)
}
return nil
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"yovision/sense/internal/device"
"yovision/sense/internal/store"
)
type manifest struct {
Site manifestSite `json:"site"`
Devices []manifestDevice `json:"devices"`
}
type manifestSite struct {
TenantID string `json:"tenant_id"`
ID string `json:"id"`
Name string `json:"name"`
MaxVideoChannels int `json:"max_video_channels"`
}
type manifestDevice struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
SiteID string `json:"site_id"`
SerialNumber string `json:"serial_number"`
Name string `json:"name"`
Capabilities []string `json:"capabilities"`
EndpointRef string `json:"endpoint_ref"`
CredentialRef string `json:"credential_ref"`
PathName string `json:"path_name"`
}
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(args []string, output io.Writer) error {
if len(args) == 0 {
return errors.New("usage: sense-lab <seed|status>")
}
switch args[0] {
case "seed":
return seed(args[1:], output)
case "status":
return status(args[1:], output)
default:
return fmt.Errorf("unknown command %q", args[0])
}
}
func seed(args []string, output io.Writer) error {
flags := flag.NewFlagSet("seed", flag.ContinueOnError)
flags.SetOutput(io.Discard)
dsn := flags.String("db", "", "SQLite DSN")
manifestPath := flags.String("manifest", "", "manifest JSON path")
if err := flags.Parse(args); err != nil {
return err
}
if *dsn == "" || *manifestPath == "" {
return errors.New("seed requires -db and -manifest")
}
file, err := os.Open(*manifestPath)
if err != nil {
return fmt.Errorf("open manifest: %w", err)
}
defer file.Close()
var value manifest
decoder := json.NewDecoder(io.LimitReader(file, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return fmt.Errorf("decode manifest: %w", err)
}
repository, err := store.OpenSQLite(context.Background(), *dsn)
if err != nil {
return err
}
defer repository.Close()
ctx := context.Background()
if err := repository.EnsureSite(ctx, device.Site{
TenantID: value.Site.TenantID, ID: value.Site.ID, Name: value.Site.Name,
MaxVideoChannels: value.Site.MaxVideoChannels,
}); err != nil {
return err
}
for _, input := range value.Devices {
capabilities := make([]device.Capability, 0, len(input.Capabilities))
for _, capability := range input.Capabilities {
capabilities = append(capabilities, device.Capability(capability))
}
if err := repository.CreateDevice(ctx, device.Device{
ID: input.ID, TenantID: input.TenantID, SiteID: input.SiteID,
SerialNumber: input.SerialNumber, Name: input.Name, Modality: device.ModalityVideo,
Capabilities: capabilities, DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
EndpointRef: input.EndpointRef, CredentialRef: input.CredentialRef, PathName: input.PathName,
}); err != nil {
return fmt.Errorf("create device %s: %w", input.ID, err)
}
}
return json.NewEncoder(output).Encode(map[string]int{"seeded": len(value.Devices)})
}
func status(args []string, output io.Writer) error {
flags := flag.NewFlagSet("status", flag.ContinueOnError)
flags.SetOutput(io.Discard)
dsn := flags.String("db", "", "SQLite DSN")
expect := flags.Int("expect", -1, "expected device count")
requireConverged := flags.Bool("require-converged", false, "fail when unconverged is non-zero")
if err := flags.Parse(args); err != nil {
return err
}
if *dsn == "" {
return errors.New("status requires -db")
}
repository, err := store.OpenSQLite(context.Background(), *dsn)
if err != nil {
return err
}
defer repository.Close()
snapshot, err := repository.ConvergenceSnapshot(context.Background())
if err != nil {
return err
}
if err := json.NewEncoder(output).Encode(snapshot); err != nil {
return err
}
if *expect >= 0 && snapshot.Total != *expect {
return fmt.Errorf("expected %d devices, got %d", *expect, snapshot.Total)
}
if *requireConverged && snapshot.Unconverged != 0 {
return fmt.Errorf("unconverged devices: %d", snapshot.Unconverged)
}
return nil
}
+94
View File
@@ -0,0 +1,94 @@
// sense-orphan is a local, PostgreSQL-backed operational command. Report is
// read-only; apply requires a fresh scan ID, actor and exact confirmation.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"yovision/sense/internal/config"
"yovision/sense/internal/metrics"
"yovision/sense/internal/mtx"
"yovision/sense/internal/orphan"
"yovision/sense/internal/store"
)
func main() {
if err := run(); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "sense-orphan failed:", err)
os.Exit(1)
}
}
func run() error {
mode := flag.String("mode", "report", "report or apply")
scanID := flag.String("scan-id", "", "fresh scan ID required by apply")
actor := flag.String("actor", "", "operator identifier required by apply")
confirm := flag.String("confirm", "", "exact confirmation: DELETE <scan-id>")
flag.Parse()
cfg, err := config.Load()
if err != nil {
return err
}
if !strings.EqualFold(strings.TrimSpace(cfg.DatabaseDriver), store.DriverPostgres) {
return errors.New("sense-orphan requires SENSE_DB_DRIVER=postgres")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
repository, err := store.OpenPostgres(ctx, cfg.DatabaseDSN)
if err != nil {
return err
}
defer repository.Close()
media, err := mtx.NewClient(cfg.MediaMTXURL, &http.Client{Timeout: cfg.ReconcileOperationTimeout})
if err != nil {
return err
}
instanceID := cfg.InstanceID
if instanceID == "" {
instanceID, err = metrics.GenerateInstanceID()
if err != nil {
return err
}
}
manager := orphan.New(repository, media, instanceID, metrics.New(instanceID, "operator"))
encoder := json.NewEncoder(os.Stdout)
switch *mode {
case "report":
scan, err := manager.Report(ctx)
if err != nil {
return err
}
return encoder.Encode(struct {
ScanID string `json:"scan_id"`
Observed int `json:"observed"`
OwnedStale int `json:"owned_stale"`
Unowned int `json:"unowned"`
SafetyAllowed bool `json:"safety_allowed"`
SafetyReason string `json:"safety_reason"`
ExpiresAt string `json:"expires_at"`
}{
ScanID: scan.ID, Observed: scan.ObservedCount,
OwnedStale: scan.OwnedStaleCount, Unowned: scan.UnownedCount,
SafetyAllowed: scan.SafetyAllowed, SafetyReason: scan.SafetyReason,
ExpiresAt: scan.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z07:00"),
})
case "apply":
result, err := manager.Apply(ctx, *scanID, *actor, *confirm)
if encodeErr := encoder.Encode(result); encodeErr != nil {
return encodeErr
}
return err
default:
return errors.New("mode must be report or apply")
}
}
+21
View File
@@ -0,0 +1,21 @@
# T-006-only publisher fixture. This is a separate MediaMTX instance; Sense
# never edits these source paths. Each path has one independent FFmpeg process.
logLevel: warn
rtspAddress: 127.0.0.1:8555
rtspTransports: [tcp]
api: false
metrics: false
rtmp: false
hls: false
webrtc: false
srt: false
moq: false
paths:
synthetic-1:
source: publisher
synthetic-2:
source: publisher
synthetic-3:
source: publisher
synthetic-4:
source: publisher
+9
View File
@@ -0,0 +1,9 @@
# MediaMTX v1.19.3 minimal control-plane configuration for local T-003 work.
# Keep the API on loopback. Production authentication/network policy is a
# later deployment concern and must be in place before any non-loopback bind.
logLevel: info
api: true
apiAddress: 127.0.0.1:9997
metrics: true
metricsAddress: 127.0.0.1:9998
paths: {}
+46
View File
@@ -0,0 +1,46 @@
module yovision/sense
go 1.26.0
toolchain go1.26.5
require (
github.com/jackc/pgx/v5 v5.10.0
github.com/oapi-codegen/runtime v1.6.0
modernc.org/sqlite v1.54.0
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/getkin/kin-openapi v0.142.0 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect
github.com/oasdiff/yaml v0.1.1 // indirect
github.com/oasdiff/yaml3 v0.0.14 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/speakeasy-api/jsonpath v0.6.3 // indirect
github.com/speakeasy-api/openapi v1.24.0 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.48.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
+234
View File
@@ -0,0 +1,234 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n8NO7w=
github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM=
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0/go.mod h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo=
github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU=
github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU=
github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI=
github.com/speakeasy-api/openapi v1.24.0 h1:opoD27rupX7zBVPq1HkIGLeMOzNNA7JalhYP8q34i04=
github.com/speakeasy-api/openapi v1.24.0/go.mod h1:g3+dIMe0AYgbbGvnlQZqesmjAVWSm9BmsjLevnefQrg=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+181
View File
@@ -0,0 +1,181 @@
// Package auth defines the verified principal boundary for Sense HTTP APIs.
package auth
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"regexp"
"strings"
)
const (
PermissionDevicesRead = "sense.devices.read"
PermissionDevicesWrite = "sense.devices.write"
)
var (
ErrUnauthenticated = errors.New("unauthenticated")
logicalIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$`)
)
type Principal struct {
SubjectID string
ActorType string
TenantID string
SiteIDs []string
Permissions map[string]struct{}
}
func (p Principal) AllowsSite(siteID string) bool {
for _, allowed := range p.SiteIDs {
if allowed == "*" || allowed == siteID {
return true
}
}
return false
}
func (p Principal) Has(permission string) bool {
_, ok := p.Permissions[permission]
return ok
}
type Authenticator interface {
Authenticate(context.Context, string) (Principal, error)
}
type staticFile struct {
Version int `json:"version"`
Principals []staticPrincipal `json:"principals"`
}
type staticPrincipal struct {
TokenSHA256 string `json:"token_sha256"`
SubjectID string `json:"subject_id"`
ActorType string `json:"actor_type"`
TenantID string `json:"tenant_id"`
SiteIDs []string `json:"site_ids"`
Permissions []string `json:"permissions"`
}
type staticEntry struct {
digest [sha256.Size]byte
principal Principal
}
// StaticSHA256 authenticates opaque tokens against externally provisioned
// SHA-256 digests. The source file is read only during process startup.
type StaticSHA256 struct {
entries []staticEntry
}
func LoadStaticSHA256(path string) (*StaticSHA256, error) {
contents, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read static authentication registry")
}
decoder := json.NewDecoder(strings.NewReader(string(contents)))
decoder.DisallowUnknownFields()
var document staticFile
if err := decoder.Decode(&document); err != nil {
return nil, errors.New("decode static authentication registry")
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return nil, errors.New("static authentication registry has trailing JSON")
}
if document.Version != 1 || len(document.Principals) == 0 {
return nil, errors.New("static authentication registry must contain version 1 principals")
}
entries := make([]staticEntry, 0, len(document.Principals))
seenDigests := make(map[string]struct{}, len(document.Principals))
for index, value := range document.Principals {
entry, validationErr := parseStaticPrincipal(value)
if validationErr != nil {
return nil, fmt.Errorf("invalid static authentication principal %d: %w", index, validationErr)
}
if _, exists := seenDigests[value.TokenSHA256]; exists {
return nil, errors.New("duplicate static authentication token digest")
}
seenDigests[value.TokenSHA256] = struct{}{}
entries = append(entries, entry)
}
return &StaticSHA256{entries: entries}, nil
}
func parseStaticPrincipal(value staticPrincipal) (staticEntry, error) {
var entry staticEntry
if len(value.TokenSHA256) != sha256.Size*2 || value.TokenSHA256 != strings.ToLower(value.TokenSHA256) {
return entry, errors.New("token_sha256 must be 64 lowercase hexadecimal characters")
}
digest, err := hex.DecodeString(value.TokenSHA256)
if err != nil {
return entry, errors.New("token_sha256 must be hexadecimal")
}
copy(entry.digest[:], digest)
if strings.TrimSpace(value.SubjectID) == "" || len(value.SubjectID) > 200 {
return entry, errors.New("subject_id must contain 1 to 200 characters")
}
if value.ActorType != "user" && value.ActorType != "service" {
return entry, errors.New("actor_type must be user or service")
}
if !logicalIDPattern.MatchString(value.TenantID) {
return entry, errors.New("tenant_id is invalid")
}
if len(value.SiteIDs) == 0 {
return entry, errors.New("site_ids must not be empty")
}
sites := make([]string, 0, len(value.SiteIDs))
seenSites := make(map[string]struct{}, len(value.SiteIDs))
for _, siteID := range value.SiteIDs {
if siteID != "*" && !logicalIDPattern.MatchString(siteID) {
return entry, errors.New("site_ids contains an invalid site")
}
if _, exists := seenSites[siteID]; exists {
return entry, errors.New("site_ids contains a duplicate")
}
seenSites[siteID] = struct{}{}
sites = append(sites, siteID)
}
permissions := make(map[string]struct{}, len(value.Permissions))
for _, permission := range value.Permissions {
if permission != PermissionDevicesRead && permission != PermissionDevicesWrite {
return entry, errors.New("permissions contains an unsupported value")
}
if _, exists := permissions[permission]; exists {
return entry, errors.New("permissions contains a duplicate")
}
permissions[permission] = struct{}{}
}
if len(permissions) == 0 {
return entry, errors.New("permissions must not be empty")
}
entry.principal = Principal{
SubjectID: value.SubjectID, ActorType: value.ActorType, TenantID: value.TenantID,
SiteIDs: sites, Permissions: permissions,
}
return entry, nil
}
func (a *StaticSHA256) Authenticate(_ context.Context, token string) (Principal, error) {
if len(token) < 22 || len(token) > 4096 {
return Principal{}, ErrUnauthenticated
}
digest := sha256.Sum256([]byte(token))
match := -1
for index := range a.entries {
if subtle.ConstantTimeCompare(digest[:], a.entries[index].digest[:]) == 1 {
match = index
}
}
if match < 0 {
return Principal{}, ErrUnauthenticated
}
return a.entries[match].principal, nil
}
+57
View File
@@ -0,0 +1,57 @@
package auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
func TestStaticSHA256AuthenticatesScopedPrincipal(t *testing.T) {
token := "0123456789abcdef0123456789abcdef"
digest := sha256.Sum256([]byte(token))
path := filepath.Join(t.TempDir(), "auth.json")
document := `{"version":1,"principals":[{` +
`"token_sha256":"` + hex.EncodeToString(digest[:]) + `",` +
`"subject_id":"operator-1","actor_type":"user","tenant_id":"tenant-a",` +
`"site_ids":["site-a"],"permissions":["sense.devices.read"]}]}`
if err := os.WriteFile(path, []byte(document), 0o600); err != nil {
t.Fatal(err)
}
authenticator, err := LoadStaticSHA256(path)
if err != nil {
t.Fatal(err)
}
principal, err := authenticator.Authenticate(context.Background(), token)
if err != nil {
t.Fatal(err)
}
if principal.TenantID != "tenant-a" || !principal.AllowsSite("site-a") ||
principal.AllowsSite("site-b") || !principal.Has(PermissionDevicesRead) {
t.Fatalf("unexpected principal: %+v", principal)
}
if _, err := authenticator.Authenticate(context.Background(), strings.Repeat("x", 32)); err == nil {
t.Fatal("unknown token authenticated")
}
}
func TestStaticSHA256RejectsUnsafeRegistry(t *testing.T) {
tests := []string{
`{"version":1,"principals":[]}`,
`{"version":1,"principals":[{"token_sha256":"ABC","subject_id":"x","actor_type":"service","tenant_id":"tenant","site_ids":["*"],"permissions":["sense.devices.read"]}]}`,
`{"version":1,"principals":[{"token_sha256":"` + strings.Repeat("0", 64) + `","subject_id":"x","actor_type":"system","tenant_id":"tenant","site_ids":["*"],"permissions":["sense.devices.read"]}]}`,
`{"version":1,"principals":[{"token_sha256":"` + strings.Repeat("0", 64) + `","subject_id":"x","actor_type":"service","tenant_id":"tenant","site_ids":["*"],"permissions":["admin"]}]}`,
}
for index, document := range tests {
path := filepath.Join(t.TempDir(), "auth.json")
if err := os.WriteFile(path, []byte(document), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadStaticSHA256(path); err == nil {
t.Fatalf("unsafe registry %d was accepted", index)
}
}
}
+275
View File
@@ -0,0 +1,275 @@
// Package config loads and validates the Sense process configuration.
package config
import (
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
const (
defaultHTTPAddress = "127.0.0.1:8080"
defaultDatabaseDriver = "sqlite"
postgresDatabaseDriver = "postgres"
defaultDatabaseDSN = "file:data/sense.db"
defaultMediaMTXURL = "http://127.0.0.1:9997"
defaultReconcilePeriod = 5 * time.Second
defaultReconcileLease = 30 * time.Second
defaultOperationTimeout = 20 * time.Second
defaultProbePeriod = 10 * time.Second
defaultOrphanScanPeriod = time.Minute
defaultONVIFMode = "disabled"
defaultControlAuthMode = "static-sha256"
)
var instanceIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
type Config struct {
HTTPAddress string
AllowNonLoopback bool
DatabaseDriver string
DatabaseDSN string
MediaMTXURL string
ReconcileInterval time.Duration
ReconcileLeaseDuration time.Duration
ReconcileOperationTimeout time.Duration
ProbeInterval time.Duration
InstanceID string
MetricsEnabled bool
OrphanScanEnabled bool
OrphanScanInterval time.Duration
ONVIFMode string
RTSPRewriteHost string
RTSPRewritePort int
RTSPStripQuery bool
ControlAPIEnabled bool
ControlAuthMode string
ControlAuthFile string
ControlCursorKeyFile string
ControlAllowInsecureHTTP bool
}
func Load() (Config, error) {
allow, err := boolEnv("SENSE_ALLOW_NON_LOOPBACK", false)
if err != nil {
return Config{}, err
}
reconcilePeriod, err := durationEnv("SENSE_RECONCILE_INTERVAL", defaultReconcilePeriod)
if err != nil {
return Config{}, err
}
reconcileLease, err := durationEnv("SENSE_RECONCILE_LEASE_DURATION", defaultReconcileLease)
if err != nil {
return Config{}, err
}
operationTimeout, err := durationEnv("SENSE_RECONCILE_OPERATION_TIMEOUT", defaultOperationTimeout)
if err != nil {
return Config{}, err
}
probePeriod, err := durationEnv("SENSE_PROBE_INTERVAL", defaultProbePeriod)
if err != nil {
return Config{}, err
}
rewritePort, err := intEnv("SENSE_ONVIF_RTSP_REWRITE_PORT", 0)
if err != nil {
return Config{}, err
}
stripQuery, err := boolEnv("SENSE_ONVIF_RTSP_STRIP_QUERY", false)
if err != nil {
return Config{}, err
}
controlEnabled, err := boolEnv("SENSE_CONTROL_API_ENABLED", false)
if err != nil {
return Config{}, err
}
controlAllowInsecure, err := boolEnv("SENSE_CONTROL_ALLOW_INSECURE_HTTP", false)
if err != nil {
return Config{}, err
}
metricsEnabled, err := boolEnv("SENSE_METRICS_ENABLED", true)
if err != nil {
return Config{}, err
}
databaseDriver := stringEnv("SENSE_DB_DRIVER", defaultDatabaseDriver)
orphanDefault := strings.EqualFold(strings.TrimSpace(databaseDriver), postgresDatabaseDriver)
orphanEnabled, err := boolEnv("SENSE_ORPHAN_SCAN_ENABLED", orphanDefault)
if err != nil {
return Config{}, err
}
orphanPeriod, err := durationEnv("SENSE_ORPHAN_SCAN_INTERVAL", defaultOrphanScanPeriod)
if err != nil {
return Config{}, err
}
cfg := Config{
HTTPAddress: stringEnv("SENSE_HTTP_ADDR", defaultHTTPAddress),
AllowNonLoopback: allow,
DatabaseDriver: databaseDriver,
DatabaseDSN: stringEnv("SENSE_DB_DSN", defaultDatabaseDSN),
MediaMTXURL: stringEnv("SENSE_MEDIAMTX_URL", defaultMediaMTXURL),
ReconcileInterval: reconcilePeriod,
ReconcileLeaseDuration: reconcileLease,
ReconcileOperationTimeout: operationTimeout,
ProbeInterval: probePeriod,
InstanceID: stringEnv("SENSE_INSTANCE_ID", ""),
MetricsEnabled: metricsEnabled,
OrphanScanEnabled: orphanEnabled,
OrphanScanInterval: orphanPeriod,
ONVIFMode: stringEnv("SENSE_ONVIF_MODE", defaultONVIFMode),
RTSPRewriteHost: stringEnv("SENSE_ONVIF_RTSP_REWRITE_HOST", ""),
RTSPRewritePort: rewritePort,
RTSPStripQuery: stripQuery,
ControlAPIEnabled: controlEnabled,
ControlAuthMode: stringEnv("SENSE_CONTROL_AUTH_MODE", defaultControlAuthMode),
ControlAuthFile: stringEnv("SENSE_CONTROL_AUTH_FILE", ""),
ControlCursorKeyFile: stringEnv("SENSE_CONTROL_CURSOR_KEY_FILE", ""),
ControlAllowInsecureHTTP: controlAllowInsecure,
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func (c Config) Validate() error {
host, _, err := net.SplitHostPort(c.HTTPAddress)
if err != nil {
return fmt.Errorf("invalid SENSE_HTTP_ADDR: %w", err)
}
ip := net.ParseIP(host)
isLoopback := host == "localhost" || (ip != nil && ip.IsLoopback())
if !isLoopback && !c.AllowNonLoopback {
return fmt.Errorf("non-loopback HTTP bind requires SENSE_ALLOW_NON_LOOPBACK=true")
}
if c.DatabaseDSN == "" {
return fmt.Errorf("SENSE_DB_DSN must not be empty")
}
databaseDriver := strings.ToLower(strings.TrimSpace(c.DatabaseDriver))
if databaseDriver == "" {
databaseDriver = defaultDatabaseDriver
}
if databaseDriver != "sqlite" && databaseDriver != "postgres" {
return fmt.Errorf("SENSE_DB_DRIVER must be sqlite or postgres")
}
if databaseDriver == "postgres" && strings.HasPrefix(c.DatabaseDSN, "file:") {
return fmt.Errorf("postgres SENSE_DB_DRIVER requires an explicit PostgreSQL SENSE_DB_DSN")
}
mediaURL, err := url.Parse(c.MediaMTXURL)
if err != nil || mediaURL.Scheme == "" || mediaURL.Host == "" {
return fmt.Errorf("invalid SENSE_MEDIAMTX_URL")
}
if mediaURL.User != nil {
return fmt.Errorf("SENSE_MEDIAMTX_URL must not contain credentials")
}
if c.ReconcileInterval <= 0 || c.ProbeInterval <= 0 {
return fmt.Errorf("loop intervals must be positive")
}
leaseDuration := c.ReconcileLeaseDuration
if leaseDuration == 0 {
leaseDuration = defaultReconcileLease
}
operationTimeout := c.ReconcileOperationTimeout
if operationTimeout == 0 {
operationTimeout = defaultOperationTimeout
}
if leaseDuration <= 0 || leaseDuration > 5*time.Minute {
return fmt.Errorf("SENSE_RECONCILE_LEASE_DURATION must be positive and at most 5m")
}
if operationTimeout <= 0 || operationTimeout >= leaseDuration {
return fmt.Errorf("SENSE_RECONCILE_OPERATION_TIMEOUT must be positive and shorter than the lease")
}
if c.InstanceID != "" && !instanceIDPattern.MatchString(c.InstanceID) {
return fmt.Errorf("invalid SENSE_INSTANCE_ID")
}
if c.OrphanScanEnabled {
if databaseDriver != postgresDatabaseDriver {
return fmt.Errorf("orphan scanning requires SENSE_DB_DRIVER=postgres")
}
orphanInterval := c.OrphanScanInterval
if orphanInterval == 0 {
orphanInterval = defaultOrphanScanPeriod
}
if orphanInterval < 10*time.Second {
return fmt.Errorf("SENSE_ORPHAN_SCAN_INTERVAL must be at least 10s")
}
}
if c.ONVIFMode != "" && c.ONVIFMode != "disabled" && c.ONVIFMode != "standard" {
return fmt.Errorf("SENSE_ONVIF_MODE must be disabled or standard")
}
if c.RTSPRewritePort < 0 || c.RTSPRewritePort > 65535 {
return fmt.Errorf("SENSE_ONVIF_RTSP_REWRITE_PORT must be between 0 and 65535")
}
if c.RTSPRewriteHost != "" {
if strings.TrimSpace(c.RTSPRewriteHost) != c.RTSPRewriteHost ||
strings.ContainsAny(c.RTSPRewriteHost, "/@") {
return fmt.Errorf("invalid SENSE_ONVIF_RTSP_REWRITE_HOST")
}
}
if c.ControlAPIEnabled {
if databaseDriver != "postgres" {
return fmt.Errorf("Sense Control API requires SENSE_DB_DRIVER=postgres")
}
if c.ControlAuthMode != defaultControlAuthMode {
return fmt.Errorf("SENSE_CONTROL_AUTH_MODE must be static-sha256")
}
if c.ControlAuthFile == "" || !filepath.IsAbs(c.ControlAuthFile) {
return fmt.Errorf("SENSE_CONTROL_AUTH_FILE must be an absolute external path")
}
if c.ControlCursorKeyFile == "" || !filepath.IsAbs(c.ControlCursorKeyFile) {
return fmt.Errorf("SENSE_CONTROL_CURSOR_KEY_FILE must be an absolute external path")
}
if !isLoopback && !c.ControlAllowInsecureHTTP {
return fmt.Errorf("non-loopback Control API requires SENSE_CONTROL_ALLOW_INSECURE_HTTP=true")
}
}
return nil
}
func stringEnv(name, fallback string) string {
if value, ok := os.LookupEnv(name); ok {
return value
}
return fallback
}
func boolEnv(name string, fallback bool) (bool, error) {
value, ok := os.LookupEnv(name)
if !ok {
return fallback, nil
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return false, fmt.Errorf("invalid %s: %w", name, err)
}
return parsed, nil
}
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
value, ok := os.LookupEnv(name)
if !ok {
return fallback, nil
}
parsed, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("invalid %s: %w", name, err)
}
return parsed, nil
}
func intEnv(name string, fallback int) (int, error) {
value, ok := os.LookupEnv(name)
if !ok {
return fallback, nil
}
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("invalid %s: %w", name, err)
}
return parsed, nil
}
+168
View File
@@ -0,0 +1,168 @@
package config
import (
"path/filepath"
"testing"
"time"
)
func TestValidateRejectsNonLoopbackByDefault(t *testing.T) {
t.Parallel()
cfg := Config{
HTTPAddress: "0.0.0.0:8080",
DatabaseDSN: "file:test.db",
MediaMTXURL: "http://127.0.0.1:9997",
ReconcileInterval: 1,
ProbeInterval: 1,
}
if err := cfg.Validate(); err == nil {
t.Fatal("expected non-loopback bind to be rejected")
}
cfg.AllowNonLoopback = true
if err := cfg.Validate(); err != nil {
t.Fatalf("explicit non-loopback opt-in failed: %v", err)
}
}
func TestValidateReconcileLeaseAndOrphanProductionBoundary(t *testing.T) {
base := Config{
HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "postgres",
DatabaseDSN: "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable",
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: time.Second,
ProbeInterval: time.Second, ReconcileLeaseDuration: 30 * time.Second,
ReconcileOperationTimeout: 20 * time.Second, OrphanScanEnabled: true,
OrphanScanInterval: time.Minute, InstanceID: "ins_edge-01",
}
if err := base.Validate(); err != nil {
t.Fatalf("valid multi-instance configuration failed: %v", err)
}
invalidTimeout := base
invalidTimeout.ReconcileOperationTimeout = invalidTimeout.ReconcileLeaseDuration
if err := invalidTimeout.Validate(); err == nil {
t.Fatal("operation timeout equal to the lease was accepted")
}
invalidInstance := base
invalidInstance.InstanceID = "tenant/site"
if err := invalidInstance.Validate(); err == nil {
t.Fatal("unbounded instance label was accepted")
}
sqlite := base
sqlite.DatabaseDriver, sqlite.DatabaseDSN = "sqlite", "file:test.db"
if err := sqlite.Validate(); err == nil {
t.Fatal("orphan scanner was accepted on SQLite")
}
}
func TestValidateRejectsCredentialsInMediaMTXURL(t *testing.T) {
t.Parallel()
cfg := Config{
HTTPAddress: "127.0.0.1:8080",
DatabaseDSN: "file:test.db",
MediaMTXURL: "http://" + "user" + ":" + "redacted" + "@127.0.0.1:9997",
ReconcileInterval: 1,
ProbeInterval: 1,
}
if err := cfg.Validate(); err == nil {
t.Fatal("expected credentials in MediaMTX URL to be rejected")
}
}
func TestValidateONVIFModeAndRewritePort(t *testing.T) {
t.Parallel()
cfg := Config{
HTTPAddress: "127.0.0.1:8080",
DatabaseDSN: "file:test.db",
MediaMTXURL: "http://127.0.0.1:9997",
ReconcileInterval: 1,
ProbeInterval: 1,
ONVIFMode: "standard",
RTSPRewriteHost: "127.0.0.1",
RTSPRewritePort: 10554,
}
if err := cfg.Validate(); err != nil {
t.Fatalf("valid ONVIF configuration failed: %v", err)
}
cfg.ONVIFMode = "vendor"
if err := cfg.Validate(); err == nil {
t.Fatal("unknown ONVIF mode must be rejected")
}
cfg.ONVIFMode = "standard"
cfg.RTSPRewritePort = 65536
if err := cfg.Validate(); err == nil {
t.Fatal("invalid RTSP rewrite port must be rejected")
}
}
func TestLoadRTSPStripQueryOptIn(t *testing.T) {
t.Setenv("SENSE_ONVIF_RTSP_STRIP_QUERY", "true")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if !cfg.RTSPStripQuery {
t.Fatal("explicit RTSP query stripping was not loaded")
}
}
func TestValidateDatabaseDriver(t *testing.T) {
t.Parallel()
cfg := Config{
HTTPAddress: "127.0.0.1:8080",
DatabaseDriver: "postgres",
DatabaseDSN: "file:test.db",
MediaMTXURL: "http://127.0.0.1:9997",
ReconcileInterval: 1,
ProbeInterval: 1,
}
if err := cfg.Validate(); err == nil {
t.Fatal("postgres driver must reject the SQLite default DSN")
}
cfg.DatabaseDSN = "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable"
if err := cfg.Validate(); err != nil {
t.Fatalf("valid PostgreSQL selection failed: %v", err)
}
cfg.DatabaseDriver = "mysql"
if err := cfg.Validate(); err == nil {
t.Fatal("unknown database driver must be rejected")
}
}
func TestValidateControlAPIRequiresPostgresAndExternalSecurityFiles(t *testing.T) {
cfg := Config{
HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "sqlite", DatabaseDSN: "file:test.db",
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: 1, ProbeInterval: 1,
ControlAPIEnabled: true, ControlAuthMode: "static-sha256",
ControlAuthFile: filepath.Join(t.TempDir(), "sense-auth.json"),
ControlCursorKeyFile: filepath.Join(t.TempDir(), "sense-cursor.key"),
}
if err := cfg.Validate(); err == nil {
t.Fatal("Control API was accepted on SQLite")
}
cfg.DatabaseDriver = "postgres"
cfg.DatabaseDSN = "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable"
if err := cfg.Validate(); err != nil {
t.Fatalf("valid Control API configuration failed: %v", err)
}
cfg.ControlAuthFile = "relative-auth.json"
if err := cfg.Validate(); err == nil {
t.Fatal("repository-relative authentication file was accepted")
}
}
func TestValidateControlAPINonLoopbackNeedsSeparateRiskAcceptance(t *testing.T) {
cfg := Config{
HTTPAddress: "0.0.0.0:8080", AllowNonLoopback: true,
DatabaseDriver: "postgres", DatabaseDSN: "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable",
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: 1, ProbeInterval: 1,
ControlAPIEnabled: true, ControlAuthMode: "static-sha256",
ControlAuthFile: filepath.Join(t.TempDir(), "sense-auth.json"),
ControlCursorKeyFile: filepath.Join(t.TempDir(), "sense-cursor.key"),
}
if err := cfg.Validate(); err == nil {
t.Fatal("non-loopback plaintext Control API was accepted without explicit risk acceptance")
}
cfg.ControlAllowInsecureHTTP = true
if err := cfg.Validate(); err != nil {
t.Fatalf("explicit non-loopback Control API risk acceptance failed: %v", err)
}
}
+5
View File
@@ -0,0 +1,5 @@
// Package controlapi implements the frozen Sense Control API v1.
package controlapi
// The input is the repository-owned public contract frozen by T-008.
//go:generate go tool oapi-codegen -config oapi-codegen.yaml ../../../docs/contracts/sense-control-v1.openapi.json
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
package controlapi
import (
"crypto/rand"
"encoding/hex"
"errors"
"math/big"
"time"
)
const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
func newULID(prefix string, now time.Time) (string, error) {
value := make([]byte, 16)
milliseconds := uint64(now.UTC().UnixMilli())
value[0] = byte(milliseconds >> 40)
value[1] = byte(milliseconds >> 32)
value[2] = byte(milliseconds >> 24)
value[3] = byte(milliseconds >> 16)
value[4] = byte(milliseconds >> 8)
value[5] = byte(milliseconds)
if _, err := rand.Read(value[6:]); err != nil {
return "", errors.New("generate identifier randomness")
}
number := new(big.Int).SetBytes(value)
base := big.NewInt(32)
remainder := new(big.Int)
encoded := make([]byte, 26)
for index := len(encoded) - 1; index >= 0; index-- {
number.QuoRem(number, base, remainder)
encoded[index] = crockford[remainder.Int64()]
}
return prefix + string(encoded), nil
}
func newTraceID() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", errors.New("generate trace identifier")
}
return "trace_" + hex.EncodeToString(value), nil
}
+66
View File
@@ -0,0 +1,66 @@
package controlapi
import (
"yovision/sense/internal/store"
)
func publicDevice(value store.ControlDevice) Device {
capabilities := make([]Capability, len(value.Capabilities))
for index := range value.Capabilities {
capabilities[index] = Capability(value.Capabilities[index])
}
tenantID := LogicalID(value.TenantID)
generation, observed := value.Generation, value.ObservedGeneration
converged, failureCount := value.Converged, value.FailureCount
endpointConfigured, credentialConfigured := value.EndpointConfigured, value.CredentialConfigured
createdAt, updatedAt := value.CreatedAt, value.UpdatedAt
result := Device{
Id: value.ID, TenantId: &tenantID, SiteId: value.SiteID,
SerialNumber: value.SerialNumber, Name: value.Name, Modality: Modality(value.Modality),
Capabilities: &capabilities, AreaId: value.AreaID,
DesiredState: DesiredState(value.DesiredState), ActualState: ActualState(value.ActualState),
AdapterStatus: AdapterStatus(value.AdapterStatus),
EndpointConfigured: &endpointConfigured, CredentialConfigured: &credentialConfigured,
Generation: &generation, ObservedGeneration: &observed, Converged: &converged,
FailureCount: &failureCount, NextAttemptAt: value.NextAttemptAt,
LastErrorCode: value.LastErrorCode, CreatedAt: &createdAt, UpdatedAt: &updatedAt,
}
if value.ProjectionVersions.QuotaSourceVersion != nil {
version := *value.ProjectionVersions.QuotaSourceVersion
result.ProjectionVersions.QuotaSourceVersion = &version
}
if value.ProjectionVersions.AreaPolicySourceVersion != nil {
version := *value.ProjectionVersions.AreaPolicySourceVersion
result.ProjectionVersions.AreaPolicySourceVersion = &version
}
result.ProjectionVersions.SyncedAt = value.ProjectionVersions.SyncedAt
return result
}
func publicQuota(value store.ControlSiteQuota) SiteQuotaStatus {
return SiteQuotaStatus{
Status: SiteQuotaStatusStatus(value.Status), UsedVideoChannels: value.UsedVideoChannels,
MaxVideoChannels: value.MaxVideoChannels,
AvailableVideoChannels: value.AvailableVideoChannels,
OverLimit: value.OverLimit, SourceVersion: value.SourceVersion, SyncedAt: value.SyncedAt,
}
}
func publicOperation(value store.ControlBatchOperation) BatchOperation {
results := make([]BatchItemResult, 0, len(value.Results))
for _, item := range value.Results {
var code *ErrorCode
if item.ErrorCode != nil {
converted := ErrorCode(*item.ErrorCode)
code = &converted
}
results = append(results, BatchItemResult{
DeviceId: item.DeviceID, Status: BatchItemResultStatus(item.Status),
ErrorCode: code, Message: item.Message, Generation: item.Generation,
})
}
return BatchOperation{
Id: value.ID, Status: BatchOperationStatus(value.Status), SubmittedAt: value.SubmittedAt,
CompletedAt: value.CompletedAt, Results: results, TraceId: value.TraceID,
}
}
@@ -0,0 +1,7 @@
package: controlapi
output: generated.gen.go
generate:
models: true
std-http-server: true
output-options:
skip-prune: false
+108
View File
@@ -0,0 +1,108 @@
package controlapi
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"os"
"strings"
"time"
)
var ErrInvalidCursor = errors.New("invalid cursor")
type CursorPosition struct {
CreatedAt time.Time
DeviceID string
}
type cursorPayload struct {
Version int `json:"v"`
TenantID string `json:"t"`
SiteID string `json:"s"`
FilterHash string `json:"f"`
CreatedAt string `json:"c"`
DeviceID string `json:"d"`
}
type CursorCodec struct {
key []byte
}
func LoadCursorCodec(path string) (*CursorCodec, error) {
contents, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read Control API cursor key")
}
key, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(contents)))
if err != nil || len(key) < 32 {
return nil, errors.New("Control API cursor key must be base64url for at least 32 bytes")
}
return NewCursorCodec(key), nil
}
func NewCursorCodec(key []byte) *CursorCodec {
copyOfKey := append([]byte(nil), key...)
return &CursorCodec{key: copyOfKey}
}
func (c *CursorCodec) Encode(tenantID, siteID, filterHash string, position CursorPosition) (string, error) {
payload, err := json.Marshal(cursorPayload{
Version: 1, TenantID: tenantID, SiteID: siteID, FilterHash: filterHash,
CreatedAt: position.CreatedAt.UTC().Format(time.RFC3339Nano), DeviceID: position.DeviceID,
})
if err != nil {
return "", errors.New("encode cursor payload")
}
encoded := base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, c.key)
_, _ = mac.Write([]byte(encoded))
return encoded + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
func (c *CursorCodec) Decode(value, tenantID, siteID, filterHash string) (CursorPosition, error) {
var position CursorPosition
parts := strings.Split(value, ".")
if len(parts) != 2 || len(value) > 512 {
return position, ErrInvalidCursor
}
provided, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || len(provided) != sha256.Size {
return position, ErrInvalidCursor
}
mac := hmac.New(sha256.New, c.key)
_, _ = mac.Write([]byte(parts[0]))
if subtle.ConstantTimeCompare(provided, mac.Sum(nil)) != 1 {
return position, ErrInvalidCursor
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return position, ErrInvalidCursor
}
var payload cursorPayload
decoder := json.NewDecoder(strings.NewReader(string(payloadBytes)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil || payload.Version != 1 ||
payload.TenantID != tenantID || payload.SiteID != siteID || payload.FilterHash != filterHash {
return position, ErrInvalidCursor
}
createdAt, err := time.Parse(time.RFC3339Nano, payload.CreatedAt)
if err != nil || payload.DeviceID == "" {
return position, ErrInvalidCursor
}
position.CreatedAt = createdAt.UTC()
position.DeviceID = payload.DeviceID
return position, nil
}
func filterFingerprint(values ...string) string {
hash := sha256.New()
for _, value := range values {
_, _ = hash.Write([]byte{byte(len(value) >> 8), byte(len(value))})
_, _ = hash.Write([]byte(value))
}
return base64.RawURLEncoding.EncodeToString(hash.Sum(nil))
}
@@ -0,0 +1,58 @@
package controlapi
import (
"bytes"
"strings"
"testing"
"time"
)
func TestCursorIsScopedAndTamperEvident(t *testing.T) {
codec := NewCursorCodec(bytes.Repeat([]byte{7}, 32))
position := CursorPosition{CreatedAt: time.Date(2026, 8, 7, 1, 2, 3, 4, time.UTC), DeviceID: "dev_1"}
value, err := codec.Encode("tenant-a", "site-a", "filters", position)
if err != nil {
t.Fatal(err)
}
decoded, err := codec.Decode(value, "tenant-a", "site-a", "filters")
if err != nil || decoded.DeviceID != position.DeviceID || !decoded.CreatedAt.Equal(position.CreatedAt) {
t.Fatalf("cursor did not round trip: %+v %v", decoded, err)
}
for name, candidate := range map[string]string{
"tenant": "tenant-b", "site": "site-b", "filter": "other",
} {
tenant, site, filter := "tenant-a", "site-a", "filters"
switch name {
case "tenant":
tenant = candidate
case "site":
site = candidate
case "filter":
filter = candidate
}
if _, err := codec.Decode(value, tenant, site, filter); err == nil {
t.Fatalf("cursor was accepted across %s scope", name)
}
}
tampered := value[:len(value)-1] + strings.ToUpper(value[len(value)-1:])
if tampered == value {
tampered = value[:len(value)-1] + "A"
}
if _, err := codec.Decode(tampered, "tenant-a", "site-a", "filters"); err == nil {
t.Fatal("tampered cursor was accepted")
}
}
func TestGeneratedIdentifiersMatchContractShape(t *testing.T) {
value, err := newULID("op_", time.Now())
if err != nil {
t.Fatal(err)
}
if len(value) != 29 || !strings.HasPrefix(value, "op_") {
t.Fatalf("invalid operation ID %q", value)
}
trace, err := newTraceID()
if err != nil || len(trace) != 38 {
t.Fatalf("invalid trace ID %q: %v", trace, err)
}
}
+601
View File
@@ -0,0 +1,601 @@
package controlapi
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"yovision/sense/internal/auth"
"yovision/sense/internal/device"
"yovision/sense/internal/store"
)
type principalContextKey struct{}
type traceContextKey struct{}
type Server struct {
store store.ControlRepository
authenticator auth.Authenticator
cursors *CursorCodec
now func() time.Time
}
func NewHTTPHandler(
repository store.ControlRepository, authenticator auth.Authenticator, cursors *CursorCodec,
) http.Handler {
server := &Server{
store: repository, authenticator: authenticator, cursors: cursors, now: time.Now,
}
generated := HandlerWithOptions(server, StdHTTPServerOptions{ErrorHandlerFunc: server.bindError})
return server.authenticate(generated)
}
func (s *Server) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
traceID, err := newTraceID()
if err != nil {
http.Error(writer, "service unavailable", http.StatusServiceUnavailable)
return
}
ctx := context.WithValue(request.Context(), traceContextKey{}, traceID)
request = request.WithContext(ctx)
values := request.Header.Values("Authorization")
if len(values) != 1 {
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
return
}
parts := strings.Fields(values[0])
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
return
}
principal, err := s.authenticator.Authenticate(request.Context(), parts[1])
if err != nil {
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
return
}
ctx = context.WithValue(request.Context(), principalContextKey{}, principal)
next.ServeHTTP(writer, request.WithContext(ctx))
})
}
func principalFromContext(ctx context.Context) auth.Principal {
value, _ := ctx.Value(principalContextKey{}).(auth.Principal)
return value
}
func traceFromContext(ctx context.Context) string {
value, _ := ctx.Value(traceContextKey{}).(string)
return value
}
func (s *Server) bindError(writer http.ResponseWriter, request *http.Request, err error) {
var required *RequiredHeaderError
if errors.As(err, &required) && required.ParamName == "If-Match" {
s.writeProblem(writer, request, http.StatusPreconditionRequired,
ErrorCodePreconditionRequired, "If-Match is required")
return
}
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "request parameters are invalid")
}
func (s *Server) requireSite(
writer http.ResponseWriter, request *http.Request, siteID, permission string,
) (auth.Principal, bool) {
principal := principalFromContext(request.Context())
if !validLogicalID(siteID) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "site_id is invalid")
return auth.Principal{}, false
}
if !principal.AllowsSite(siteID) {
s.writeProblem(writer, request, http.StatusNotFound, ErrorCodeNotFound, "resource was not found")
return auth.Principal{}, false
}
if !principal.Has(permission) {
s.writeProblem(writer, request, http.StatusForbidden, ErrorCodeForbidden, "permission is required")
return auth.Principal{}, false
}
return principal, true
}
func (s *Server) writeProblem(
writer http.ResponseWriter, request *http.Request, status int, code ErrorCode, message string,
) {
traceID := traceFromContext(request.Context())
writer.Header().Set("Content-Type", "application/problem+json")
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("X-Trace-ID", traceID)
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(Problem{
Type: "/problems/" + string(code), Title: http.StatusText(status), Status: status,
Code: code, Message: message, TraceId: traceID, FieldErrors: []FieldError{},
})
}
func writeJSON(writer http.ResponseWriter, status int, traceID string, value any) {
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("X-Trace-ID", traceID)
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(value)
}
func (s *Server) ListDevices(
writer http.ResponseWriter, request *http.Request, siteID SiteID, params ListDevicesParams,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesRead)
if !ok {
return
}
limit := 50
if params.Limit != nil {
limit = *params.Limit
}
if limit < 1 || limit > 100 ||
(params.Modality != nil && !Modality(*params.Modality).Valid()) ||
(params.Capability != nil && !Capability(*params.Capability).Valid()) ||
(params.DesiredState != nil && !DesiredState(*params.DesiredState).Valid()) ||
(params.ActualState != nil && !ActualState(*params.ActualState).Valid()) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "list filters are invalid")
return
}
filterHash := filterFingerprint(
optionalString(params.Modality), optionalString(params.Capability),
optionalString(params.DesiredState), optionalString(params.ActualState),
)
filter := store.ControlListFilter{Limit: limit}
if params.Modality != nil {
value := device.Modality(*params.Modality)
filter.Modality = &value
}
if params.Capability != nil {
value := device.Capability(*params.Capability)
filter.Capability = &value
}
if params.DesiredState != nil {
value := device.DesiredState(*params.DesiredState)
filter.DesiredState = &value
}
if params.ActualState != nil {
value := device.ActualState(*params.ActualState)
filter.ActualState = &value
}
if params.Cursor != nil {
position, err := s.cursors.Decode(*params.Cursor, principal.TenantID, siteID, filterHash)
if err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "cursor is invalid")
return
}
filter.AfterCreated = &position.CreatedAt
filter.AfterDeviceID = position.DeviceID
}
page, err := s.store.ListControlDevices(request.Context(), principal.TenantID, siteID, filter)
if err != nil {
s.writeStoreError(writer, request, err, false)
return
}
items := make([]Device, 0, len(page.Items))
for _, value := range page.Items {
items = append(items, publicDevice(value))
}
var nextCursor *string
if page.HasMore && len(page.Items) > 0 {
last := page.Items[len(page.Items)-1]
value, err := s.cursors.Encode(principal.TenantID, siteID, filterHash,
CursorPosition{CreatedAt: last.CreatedAt, DeviceID: last.ID})
if err != nil {
s.writeProblem(writer, request, http.StatusInternalServerError, ErrorCodeInternalError, "response could not be created")
return
}
nextCursor = &value
}
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), DevicePage{
Items: items, Page: PageInfo{Limit: limit, HasMore: page.HasMore, NextCursor: nextCursor},
Quota: publicQuota(page.Quota),
})
}
func optionalString[T ~string](value *T) string {
if value == nil {
return ""
}
return string(*value)
}
func (s *Server) CreateDevice(
writer http.ResponseWriter, request *http.Request, siteID SiteID, params CreateDeviceParams,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
if !ok {
return
}
if !idempotencyKeyRegex.MatchString(params.IdempotencyKey) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "Idempotency-Key is invalid")
return
}
contents, err := readRequestBody(request, "application/json")
if err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
if err := rejectTopLevelNulls(contents); err != nil {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, err.Error())
return
}
var body DeviceCreate
if err := decodeStrictJSON(contents, &body); err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
if code, message := validateDeviceCreate(body); code != "" {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCode(code), message)
return
}
desired := Disabled
if body.DesiredState != nil {
desired = *body.DesiredState
}
body.DesiredState = &desired
canonical, _ := json.Marshal(body)
requestHash := sha256.Sum256(canonical)
now := s.now().UTC()
deviceID, err := newULID("dev_", now)
if err != nil {
s.writeProblem(writer, request, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "identifier could not be generated")
return
}
capabilities := make([]device.Capability, len(body.Capabilities))
for index := range body.Capabilities {
capabilities[index] = device.Capability(body.Capabilities[index])
}
value := device.Device{
ID: deviceID, TenantID: principal.TenantID, SiteID: siteID, AreaID: body.AreaId,
SerialNumber: body.SerialNumber, Name: body.Name, Modality: device.Modality(body.Modality),
Capabilities: capabilities, DesiredState: device.DesiredState(desired),
ActualState: device.ActualPending, PathName: "devices/" + deviceID,
Generation: 1, ResourceVersion: 1,
}
if body.EndpointRef != nil {
value.EndpointRef = *body.EndpointRef
}
if body.CredentialRef != nil {
value.CredentialRef = *body.CredentialRef
}
if body.ProfileToken != nil {
value.ProfileToken = *body.ProfileToken
}
traceID := traceFromContext(request.Context())
ctx := store.WithAuditContext(request.Context(), auditContext(principal, "", traceID))
result, err := s.store.CreateControlDevice(ctx, store.ControlCreateRequest{
Scope: store.IdempotencyScope{
PrincipalID: principal.SubjectID, TenantID: principal.TenantID, SiteID: siteID,
Operation: "createDevice", Key: params.IdempotencyKey,
RequestHash: requestHash, TraceID: traceID,
},
Device: value,
})
if err != nil {
s.writeStoreError(writer, request, err, true)
return
}
writer.Header().Set("ETag", result.ETag)
writer.Header().Set("Location", result.Location)
writeJSON(writer, http.StatusCreated, result.TraceID, publicDevice(result.Device))
}
func validateDeviceCreate(body DeviceCreate) (string, string) {
if !validLogicalID(body.AreaId) || !validLength(body.SerialNumber, 1, 128) ||
!validLength(body.Name, 1, 200) || strings.TrimSpace(body.SerialNumber) == "" ||
strings.TrimSpace(body.Name) == "" || !body.Modality.Valid() ||
len(body.Capabilities) < 1 || len(body.Capabilities) > 16 {
return "invalid_request", "device fields are invalid"
}
seen := make(map[Capability]struct{}, len(body.Capabilities))
hasVideo := false
for _, capability := range body.Capabilities {
if !capability.Valid() {
return "invalid_request", "device capability is invalid"
}
if _, exists := seen[capability]; exists {
return "invalid_request", "device capabilities contain a duplicate"
}
seen[capability] = struct{}{}
hasVideo = hasVideo || capability == VideoCapture
}
if body.Modality == Video && !hasVideo {
return "invalid_request", "video modality requires video_capture"
}
if hasVideo && (body.EndpointRef == nil || body.CredentialRef == nil) {
return "adapter_not_ready", "video capture requires endpoint and credential references"
}
if body.EndpointRef != nil && !validateEndpoint(*body.EndpointRef) {
return "endpoint_credentials_forbidden", "endpoint reference is invalid"
}
if body.CredentialRef != nil && (!validLength(*body.CredentialRef, 1, 512) || strings.TrimSpace(*body.CredentialRef) == "") {
return "endpoint_credentials_forbidden", "credential reference is invalid"
}
if body.ProfileToken != nil && !validLength(*body.ProfileToken, 1, 256) {
return "invalid_request", "profile token is invalid"
}
if body.DesiredState != nil && !body.DesiredState.Valid() {
return "invalid_request", "desired_state is invalid"
}
return "", ""
}
func auditContext(principal auth.Principal, reason, traceID string) store.AuditContext {
actorType := store.AuditActorService
if principal.ActorType == "user" {
actorType = store.AuditActorUser
}
return store.AuditContext{
ActorType: actorType, ActorID: principal.SubjectID, Reason: reason, TraceID: traceID,
}
}
func (s *Server) GetDevice(
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesRead)
if !ok {
return
}
if !validLogicalID(deviceID) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id is invalid")
return
}
value, err := s.store.GetControlDevice(request.Context(), principal.TenantID, siteID, deviceID)
if err != nil {
s.writeStoreError(writer, request, err, false)
return
}
writer.Header().Set("ETag", store.DeviceETag(value.ID, value.ResourceVersion))
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), publicDevice(value))
}
func (s *Server) UpdateDevice(
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
params UpdateDeviceParams,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
if !ok {
return
}
if !validLogicalID(deviceID) || !validStrongETag(params.IfMatch) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id or If-Match is invalid")
return
}
contents, err := readRequestBody(request, "application/merge-patch+json")
if err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(contents, &fields); err != nil || len(fields) == 0 {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "patch must contain a field")
return
}
for _, value := range fields {
if string(value) == "null" {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "patch fields cannot be null")
return
}
}
var body DevicePatch
if err := decodeStrictJSON(contents, &body); err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
if code, message := validateDevicePatch(body); code != "" {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCode(code), message)
return
}
patch := store.ControlPatch{
Name: body.Name, EndpointRef: body.EndpointRef,
CredentialRef: body.CredentialRef, ProfileToken: body.ProfileToken,
}
if body.AreaId != nil {
value := string(*body.AreaId)
patch.AreaID = &value
}
traceID := traceFromContext(request.Context())
ctx := store.WithAuditContext(request.Context(), auditContext(principal, "", traceID))
result, err := s.store.PatchControlDevice(
ctx, principal.TenantID, siteID, deviceID, params.IfMatch, patch,
)
if err != nil {
s.writeStoreError(writer, request, err, true)
return
}
writer.Header().Set("ETag", result.ETag)
writeJSON(writer, http.StatusAccepted, result.TraceID, MutationReceipt{
Device: publicDevice(result.Device), AcceptedAt: result.AcceptedAt, TraceId: result.TraceID,
})
}
func validateDevicePatch(body DevicePatch) (string, string) {
if body.Name != nil && (!validLength(*body.Name, 1, 200) || strings.TrimSpace(*body.Name) == "") {
return "invalid_request", "name is invalid"
}
if body.AreaId != nil && !validLogicalID(*body.AreaId) {
return "invalid_request", "area_id is invalid"
}
if body.EndpointRef != nil && !validateEndpoint(*body.EndpointRef) {
return "endpoint_credentials_forbidden", "endpoint reference is invalid"
}
if body.CredentialRef != nil && (!validLength(*body.CredentialRef, 1, 512) || strings.TrimSpace(*body.CredentialRef) == "") {
return "endpoint_credentials_forbidden", "credential reference is invalid"
}
if body.ProfileToken != nil && !validLength(*body.ProfileToken, 1, 256) {
return "invalid_request", "profile token is invalid"
}
return "", ""
}
func (s *Server) SetDeviceDesiredState(
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
params SetDeviceDesiredStateParams,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
if !ok {
return
}
if !validLogicalID(deviceID) || !validStrongETag(params.IfMatch) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id or If-Match is invalid")
return
}
contents, err := readRequestBody(request, "application/json")
if err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
var body DesiredStateChange
if err := decodeStrictJSON(contents, &body); err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
if !body.DesiredState.Valid() || !validLength(body.Reason, 1, 500) || strings.TrimSpace(body.Reason) == "" {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "desired_state or reason is invalid")
return
}
traceID := traceFromContext(request.Context())
ctx := store.WithAuditContext(request.Context(), auditContext(principal, body.Reason, traceID))
result, err := s.store.SetControlDesiredState(
ctx, principal.TenantID, siteID, deviceID, params.IfMatch,
device.DesiredState(body.DesiredState),
)
if err != nil {
s.writeStoreError(writer, request, err, true)
return
}
writer.Header().Set("ETag", result.ETag)
writeJSON(writer, http.StatusAccepted, result.TraceID, MutationReceipt{
Device: publicDevice(result.Device), AcceptedAt: result.AcceptedAt, TraceId: result.TraceID,
})
}
func (s *Server) BatchSetDeviceDesiredState(
writer http.ResponseWriter, request *http.Request, siteID SiteID,
params BatchSetDeviceDesiredStateParams,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
if !ok {
return
}
if !idempotencyKeyRegex.MatchString(params.IdempotencyKey) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "Idempotency-Key is invalid")
return
}
contents, err := readRequestBody(request, "application/json")
if err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
var body BatchDesiredStateRequest
if err := decodeStrictJSON(contents, &body); err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
if len(body.Items) > 128 {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeBatchTooLarge, "batch contains more than 128 items")
return
}
if len(body.Items) == 0 || !validLength(body.Reason, 1, 500) || strings.TrimSpace(body.Reason) == "" {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "batch items or reason is invalid")
return
}
items := make([]store.ControlBatchItem, 0, len(body.Items))
for _, item := range body.Items {
if !validLogicalID(item.DeviceId) || !validStrongETag(item.Etag) || !item.DesiredState.Valid() {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "batch item is invalid")
return
}
items = append(items, store.ControlBatchItem{
DeviceID: item.DeviceId, ETag: item.Etag,
DesiredState: device.DesiredState(item.DesiredState),
})
}
canonical, _ := json.Marshal(body)
requestHash := sha256.Sum256(canonical)
traceID := traceFromContext(request.Context())
ctx := store.WithAuditContext(request.Context(), auditContext(principal, body.Reason, traceID))
operation, err := s.store.BatchSetControlDesiredState(ctx, store.ControlBatchRequest{
Scope: store.IdempotencyScope{
PrincipalID: principal.SubjectID, TenantID: principal.TenantID, SiteID: siteID,
Operation: "batchSetDeviceDesiredState", Key: params.IdempotencyKey,
RequestHash: requestHash, TraceID: traceID,
},
Reason: body.Reason, Items: items,
})
if err != nil {
s.writeStoreError(writer, request, err, true)
return
}
writer.Header().Set("Location", "/api/v1/operations/"+operation.ID)
writeJSON(writer, http.StatusAccepted, operation.TraceID, publicOperation(operation))
}
func (s *Server) GetOperation(
writer http.ResponseWriter, request *http.Request, operationID OperationID,
) {
principal := principalFromContext(request.Context())
if !principal.Has(auth.PermissionDevicesRead) {
s.writeProblem(writer, request, http.StatusForbidden, ErrorCodeForbidden, "permission is required")
return
}
if !operationIDRegex.MatchString(operationID) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "operation_id is invalid")
return
}
operation, err := s.store.GetControlOperation(request.Context(), principal.TenantID, operationID)
if err != nil {
s.writeStoreError(writer, request, err, false)
return
}
if !principal.AllowsSite(operation.SiteID) {
s.writeProblem(writer, request, http.StatusNotFound, ErrorCodeNotFound, "resource was not found")
return
}
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), publicOperation(operation))
}
func (s *Server) writeStoreError(
writer http.ResponseWriter, request *http.Request, err error, mutation bool,
) {
status, code, message := http.StatusInternalServerError, ErrorCodeInternalError, "request could not be completed"
switch {
case errors.Is(err, store.ErrNotFound):
status, code, message = http.StatusNotFound, ErrorCodeNotFound, "resource was not found"
case errors.Is(err, store.ErrETagMismatch):
status, code, message = http.StatusPreconditionFailed, ErrorCodeEtagMismatch, "device ETag does not match"
case errors.Is(err, store.ErrIdempotencyConflict):
status, code, message = http.StatusConflict, ErrorCodeIdempotencyConflict, "Idempotency-Key was used with another request"
case errors.Is(err, store.ErrDuplicateSerialNumber):
status, code, message = http.StatusConflict, ErrorCodeDuplicateSerialNumber, "serial_number already exists in this site"
case errors.Is(err, store.ErrAreaPolicyDenied):
status, code, message = http.StatusUnprocessableEntity, ErrorCodeAreaPolicyDenied, "Area policy denies this change"
case errors.Is(err, store.ErrAreaPolicyUnavailable), errors.Is(err, store.ErrAreaPolicyInvalid):
status, code, message = http.StatusServiceUnavailable, ErrorCodeAreaPolicyUnavailable, "Area policy is unavailable"
case errors.Is(err, store.ErrQuotaProjectionUnavailable):
status, code, message = http.StatusServiceUnavailable, ErrorCodeQuotaProjectionUnavailable, "Site quota is unavailable"
case errors.Is(err, store.ErrQuotaProjectionInvalid):
status, code, message = http.StatusServiceUnavailable, ErrorCodeQuotaProjectionInvalid, "Site quota is invalid"
default:
var quotaError *device.QuotaExceededError
if errors.As(err, &quotaError) {
status, code, message = http.StatusConflict, ErrorCodeQuotaExceeded, "Site video channel quota is exceeded"
} else if mutation {
status, code, message = http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "device change could not be accepted"
}
}
s.writeProblem(writer, request, status, code, message)
}
var _ ServerInterface = (*Server)(nil)
+272
View File
@@ -0,0 +1,272 @@
package controlapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"yovision/sense/internal/auth"
"yovision/sense/internal/device"
"yovision/sense/internal/store"
)
const testOperationID = "op_01K20Y8Q3E0000000000000000"
type fixedAuthenticator struct {
principal auth.Principal
err error
}
func (a fixedAuthenticator) Authenticate(context.Context, string) (auth.Principal, error) {
return a.principal, a.err
}
type fakeControlStore struct {
device store.ControlDevice
err error
}
func (f *fakeControlStore) ListControlDevices(context.Context, string, string, store.ControlListFilter) (store.ControlDevicePage, error) {
return store.ControlDevicePage{
Items: []store.ControlDevice{f.device}, Quota: store.ControlSiteQuota{Status: "current"},
}, f.err
}
func (f *fakeControlStore) CreateControlDevice(_ context.Context, request store.ControlCreateRequest) (store.ControlCreateResult, error) {
return store.ControlCreateResult{
Device: f.device, AcceptedAt: time.Now(), TraceID: request.Scope.TraceID,
ETag: store.DeviceETag(f.device.ID, f.device.ResourceVersion),
Location: "/api/v1/sites/site-a/devices/" + f.device.ID,
}, f.err
}
func (f *fakeControlStore) GetControlDevice(context.Context, string, string, string) (store.ControlDevice, error) {
return f.device, f.err
}
func (f *fakeControlStore) PatchControlDevice(_ context.Context, _, _, _, _ string, _ store.ControlPatch) (store.ControlMutationResult, error) {
return f.mutation(), f.err
}
func (f *fakeControlStore) SetControlDesiredState(_ context.Context, _, _, _, _ string, _ device.DesiredState) (store.ControlMutationResult, error) {
return f.mutation(), f.err
}
func (f *fakeControlStore) mutation() store.ControlMutationResult {
return store.ControlMutationResult{
Device: f.device, AcceptedAt: time.Now(), TraceID: "trace_0123456789abcdef0123456789abcdef",
ETag: store.DeviceETag(f.device.ID, f.device.ResourceVersion),
}
}
func (f *fakeControlStore) BatchSetControlDesiredState(_ context.Context, request store.ControlBatchRequest) (store.ControlBatchOperation, error) {
completed := time.Now().UTC()
generation := f.device.Generation
return store.ControlBatchOperation{
ID: testOperationID, TenantID: request.Scope.TenantID, SiteID: request.Scope.SiteID,
Status: "succeeded", SubmittedAt: completed, CompletedAt: &completed,
TraceID: request.Scope.TraceID,
Results: []store.ControlBatchItemResult{{
DeviceID: f.device.ID, Status: "succeeded", Generation: &generation,
}},
}, f.err
}
func (f *fakeControlStore) GetControlOperation(context.Context, string, string) (store.ControlBatchOperation, error) {
completed := time.Now().UTC()
return store.ControlBatchOperation{
ID: testOperationID, TenantID: "tenant-a", SiteID: "site-a", Status: "succeeded",
SubmittedAt: completed, CompletedAt: &completed, Results: []store.ControlBatchItemResult{},
TraceID: "trace_0123456789abcdef0123456789abcdef",
}, f.err
}
func testHTTPHandler(repository *fakeControlStore, permissions ...string) http.Handler {
grants := make(map[string]struct{}, len(permissions))
for _, permission := range permissions {
grants[permission] = struct{}{}
}
principal := auth.Principal{
SubjectID: "operator-1", ActorType: "user", TenantID: "tenant-a",
SiteIDs: []string{"site-a"}, Permissions: grants,
}
return NewHTTPHandler(repository, fixedAuthenticator{principal: principal}, NewCursorCodec(bytes.Repeat([]byte{4}, 32)))
}
func testControlDevice() store.ControlDevice {
quotaVersion, areaVersion := int64(1), int64(2)
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
return store.ControlDevice{
ID: "dev_01K20Y8Q3E0000000000000000", TenantID: "tenant-a", SiteID: "site-a",
SerialNumber: "serial-1", Name: "Camera", Modality: device.ModalityVideo,
Capabilities: []device.Capability{device.CapabilityVideoCapture}, AreaID: "area-a",
DesiredState: device.DesiredDisabled, ActualState: device.ActualPending,
AdapterStatus: "pending", EndpointConfigured: true, CredentialConfigured: true,
Generation: 1, ResourceVersion: 1, FailureCount: 0,
ProjectionVersions: store.ControlProjectionVersions{
QuotaSourceVersion: &quotaVersion, AreaPolicySourceVersion: &areaVersion, SyncedAt: &now,
},
CreatedAt: now, UpdatedAt: now,
}
}
func performRequest(handler http.Handler, method, path, contentType, body string) *httptest.ResponseRecorder {
request := httptest.NewRequest(method, path, strings.NewReader(body))
request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
return recorder
}
func TestAllFrozenControlRoutesReturnContractShapes(t *testing.T) {
value := testControlDevice()
repository := &fakeControlStore{device: value}
handler := testHTTPHandler(repository, auth.PermissionDevicesRead, auth.PermissionDevicesWrite)
etag := store.DeviceETag(value.ID, value.ResourceVersion)
list := performRequest(handler, http.MethodGet, "/api/v1/sites/site-a/devices", "", "")
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"quota"`) {
t.Fatalf("list route failed: %d %s", list.Code, list.Body.String())
}
createBody := `{"serial_number":"serial-1","name":"Camera","modality":"video",` +
`"capabilities":["video_capture"],"area_id":"area-a",` +
`"endpoint_ref":"onvif://camera","credential_ref":"env://CAMERA"}`
create := httptest.NewRequest(http.MethodPost, "/api/v1/sites/site-a/devices", strings.NewReader(createBody))
create.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
create.Header.Set("Content-Type", "application/json")
create.Header.Set("Idempotency-Key", "create-request-0001")
createResult := httptest.NewRecorder()
handler.ServeHTTP(createResult, create)
if createResult.Code != http.StatusCreated || createResult.Header().Get("ETag") == "" ||
strings.Contains(createResult.Body.String(), "endpoint_ref") || strings.Contains(createResult.Body.String(), "credential_ref") {
t.Fatalf("create route failed or leaked write-only data: %d %s", createResult.Code, createResult.Body.String())
}
get := performRequest(handler, http.MethodGet, "/api/v1/sites/site-a/devices/"+value.ID, "", "")
if get.Code != http.StatusOK || get.Header().Get("ETag") != etag {
t.Fatalf("get route failed: %d %s", get.Code, get.Body.String())
}
patch := httptest.NewRequest(http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID, strings.NewReader(`{"name":"Updated"}`))
patch.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
patch.Header.Set("Content-Type", "application/merge-patch+json")
patch.Header.Set("If-Match", etag)
patchResult := httptest.NewRecorder()
handler.ServeHTTP(patchResult, patch)
if patchResult.Code != http.StatusAccepted || !strings.Contains(patchResult.Body.String(), `"accepted_at"`) {
t.Fatalf("patch route failed: %d %s", patchResult.Code, patchResult.Body.String())
}
desired := httptest.NewRequest(http.MethodPut, "/api/v1/sites/site-a/devices/"+value.ID+"/desired-state", strings.NewReader(`{"desired_state":"enabled","reason":"test"}`))
desired.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
desired.Header.Set("Content-Type", "application/json")
desired.Header.Set("If-Match", etag)
desiredResult := httptest.NewRecorder()
handler.ServeHTTP(desiredResult, desired)
if desiredResult.Code != http.StatusAccepted {
t.Fatalf("desired-state route failed: %d %s", desiredResult.Code, desiredResult.Body.String())
}
batchBody := `{"items":[{"device_id":"` + value.ID + `","etag":"` + strings.ReplaceAll(etag, `"`, `\"`) +
`","desired_state":"enabled"}],"reason":"test"}`
batch := httptest.NewRequest(http.MethodPost, "/api/v1/sites/site-a/devices:batchDesiredState", strings.NewReader(batchBody))
batch.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
batch.Header.Set("Content-Type", "application/json")
batch.Header.Set("Idempotency-Key", "batch-request-0001")
batchResult := httptest.NewRecorder()
handler.ServeHTTP(batchResult, batch)
if batchResult.Code != http.StatusAccepted || batchResult.Header().Get("Location") == "" {
t.Fatalf("batch route failed: %d %s", batchResult.Code, batchResult.Body.String())
}
operation := performRequest(handler, http.MethodGet, "/api/v1/operations/"+testOperationID, "", "")
if operation.Code != http.StatusOK || !strings.Contains(operation.Body.String(), `"results"`) {
t.Fatalf("operation route failed: %d %s", operation.Code, operation.Body.String())
}
}
func TestAuthenticationScopeAndPreconditionsUseStableProblems(t *testing.T) {
value := testControlDevice()
handler := testHTTPHandler(&fakeControlStore{device: value}, auth.PermissionDevicesRead)
unauthenticated := httptest.NewRecorder()
handler.ServeHTTP(unauthenticated, httptest.NewRequest(http.MethodGet, "/api/v1/sites/site-a/devices", nil))
if unauthenticated.Code != http.StatusUnauthorized || unauthenticated.Header().Get("WWW-Authenticate") == "" {
t.Fatalf("missing auth did not return 401: %d", unauthenticated.Code)
}
hidden := performRequest(handler, http.MethodGet, "/api/v1/sites/site-b/devices", "", "")
if hidden.Code != http.StatusNotFound {
t.Fatalf("out-of-scope site was not hidden: %d", hidden.Code)
}
missingPrecondition := performRequest(handler, http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID,
"application/merge-patch+json", `{"name":"Updated"}`)
if missingPrecondition.Code != http.StatusPreconditionRequired {
t.Fatalf("missing If-Match did not return 428: %d %s", missingPrecondition.Code, missingPrecondition.Body.String())
}
var problem Problem
if err := json.Unmarshal(missingPrecondition.Body.Bytes(), &problem); err != nil || problem.Code != ErrorCodePreconditionRequired || problem.TraceId == "" {
t.Fatalf("invalid Problem response: %+v %v", problem, err)
}
}
func TestStrictJSONAndStoreConflictsAreMapped(t *testing.T) {
value := testControlDevice()
handler := testHTTPHandler(&fakeControlStore{device: value}, auth.PermissionDevicesWrite)
etag := store.DeviceETag(value.ID, value.ResourceVersion)
duplicate := httptest.NewRequest(http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID,
strings.NewReader(`{"name":"one","name":"two"}`))
duplicate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
duplicate.Header.Set("Content-Type", "application/merge-patch+json")
duplicate.Header.Set("If-Match", etag)
result := httptest.NewRecorder()
handler.ServeHTTP(result, duplicate)
if result.Code != http.StatusBadRequest {
t.Fatalf("duplicate JSON property was accepted: %d %s", result.Code, result.Body.String())
}
nullCreate := httptest.NewRequest(http.MethodPost, "/api/v1/sites/site-a/devices", strings.NewReader(
`{"serial_number":"serial","name":"Camera","modality":"radar",`+
`"capabilities":["telemetry"],"area_id":"area-a","profile_token":null}`))
nullCreate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
nullCreate.Header.Set("Content-Type", "application/json")
nullCreate.Header.Set("Idempotency-Key", "null-create-0001")
nullResult := httptest.NewRecorder()
handler.ServeHTTP(nullResult, nullCreate)
if nullResult.Code != http.StatusUnprocessableEntity {
t.Fatalf("explicit null was accepted: %d %s", nullResult.Code, nullResult.Body.String())
}
conflictHandler := testHTTPHandler(&fakeControlStore{device: value, err: store.ErrETagMismatch}, auth.PermissionDevicesWrite)
request := httptest.NewRequest(http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID, strings.NewReader(`{"name":"two"}`))
request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
request.Header.Set("Content-Type", "application/merge-patch+json")
request.Header.Set("If-Match", etag)
recorder := httptest.NewRecorder()
conflictHandler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusPreconditionFailed || !strings.Contains(recorder.Body.String(), "etag_mismatch") {
t.Fatalf("ETag mismatch was not mapped: %d %s", recorder.Code, recorder.Body.String())
}
}
func TestProjectionVersionsAlwaysEmitRequiredNullableKeys(t *testing.T) {
encoded, err := json.Marshal(ProjectionVersions{})
if err != nil {
t.Fatal(err)
}
for _, key := range []string{"quota_source_version", "area_policy_source_version", "synced_at"} {
if !strings.Contains(string(encoded), `"`+key+`":null`) {
t.Fatalf("required nullable key %s was omitted: %s", key, encoded)
}
}
}
+153
View File
@@ -0,0 +1,153 @@
package controlapi
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"regexp"
"strings"
"unicode/utf8"
)
const maximumRequestBody = 1 << 20
var (
logicalIDRegex = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$`)
idempotencyKeyRegex = regexp.MustCompile(`^[A-Za-z0-9._:-]{16,128}$`)
operationIDRegex = regexp.MustCompile(`^op_[0-9A-HJKMNP-TV-Z]{26}$`)
strongETagRegex = regexp.MustCompile(`^"[A-Za-z0-9_-]{24}"$`)
)
func readRequestBody(request *http.Request, expectedMediaType string) ([]byte, error) {
mediaType, _, err := mime.ParseMediaType(request.Header.Get("Content-Type"))
if err != nil || mediaType != expectedMediaType {
return nil, fmt.Errorf("Content-Type must be %s", expectedMediaType)
}
contents, err := io.ReadAll(io.LimitReader(request.Body, maximumRequestBody+1))
if err != nil {
return nil, errors.New("read request body")
}
if len(contents) == 0 || len(contents) > maximumRequestBody {
return nil, errors.New("request body is empty or too large")
}
if err := rejectDuplicateJSONKeys(contents); err != nil {
return nil, err
}
return contents, nil
}
func decodeStrictJSON(contents []byte, destination any) error {
decoder := json.NewDecoder(bytes.NewReader(contents))
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return errors.New("request body does not match the API schema")
}
if decoder.Decode(&struct{}{}) != io.EOF {
return errors.New("request body contains trailing JSON")
}
return nil
}
func rejectTopLevelNulls(contents []byte) error {
var fields map[string]json.RawMessage
if err := json.Unmarshal(contents, &fields); err != nil || fields == nil {
return errors.New("request body must be a JSON object")
}
for _, value := range fields {
if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
return errors.New("request body properties cannot be null")
}
}
return nil
}
func rejectDuplicateJSONKeys(contents []byte) error {
decoder := json.NewDecoder(bytes.NewReader(contents))
decoder.UseNumber()
var visit func(int) error
visit = func(depth int) error {
if depth > 64 {
return errors.New("request body nesting is too deep")
}
token, err := decoder.Token()
if err != nil {
return errors.New("request body is not valid JSON")
}
delimiter, ok := token.(json.Delim)
if !ok {
return nil
}
switch delimiter {
case '{':
seen := make(map[string]struct{})
for decoder.More() {
keyToken, err := decoder.Token()
if err != nil {
return errors.New("request body is not valid JSON")
}
key, ok := keyToken.(string)
if !ok {
return errors.New("request body is not a JSON object")
}
if _, exists := seen[key]; exists {
return errors.New("request body contains a duplicate property")
}
seen[key] = struct{}{}
if err := visit(depth + 1); err != nil {
return err
}
}
end, err := decoder.Token()
if err != nil || end != json.Delim('}') {
return errors.New("request body is not valid JSON")
}
case '[':
for decoder.More() {
if err := visit(depth + 1); err != nil {
return err
}
}
end, err := decoder.Token()
if err != nil || end != json.Delim(']') {
return errors.New("request body is not valid JSON")
}
default:
return errors.New("request body is not valid JSON")
}
return nil
}
if err := visit(0); err != nil {
return err
}
if _, err := decoder.Token(); err != io.EOF {
return errors.New("request body contains trailing JSON")
}
return nil
}
func validLogicalID(value string) bool {
return logicalIDRegex.MatchString(value)
}
func validLength(value string, minimum, maximum int) bool {
length := utf8.RuneCountInString(value)
return utf8.ValidString(value) && length >= minimum && length <= maximum
}
func validateEndpoint(value string) bool {
if !validLength(value, 1, 2048) {
return false
}
parsed, err := url.Parse(value)
return err == nil && parsed.Scheme != "" && parsed.User == nil &&
!strings.ContainsAny(value, "\r\n")
}
func validStrongETag(value string) bool {
return strongETagRegex.MatchString(value) && value != "*" && !strings.Contains(value, ",")
}
+19
View File
@@ -0,0 +1,19 @@
package controlapi
import "encoding/json"
// MarshalJSON preserves the OpenAPI-required nullable projection keys. The
// generator represents JSON null as nil pointers but marks read-only pointers
// omitempty, so the default encoder would otherwise violate the v1 wire shape.
func (value ProjectionVersions) MarshalJSON() ([]byte, error) {
type projectionWire struct {
QuotaSourceVersion *int64 `json:"quota_source_version"`
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
SyncedAt any `json:"synced_at"`
}
return json.Marshal(projectionWire{
QuotaSourceVersion: value.QuotaSourceVersion,
AreaPolicySourceVersion: value.AreaPolicySourceVersion,
SyncedAt: value.SyncedAt,
})
}
+184
View File
@@ -0,0 +1,184 @@
// Package device contains the Sense device-ledger domain model.
package device
import (
"errors"
"fmt"
"net/url"
"slices"
"strings"
"time"
)
const (
DefaultVideoChannels = 16
MaximumVideoChannels = 128
)
type Modality string
const (
ModalityVideo Modality = "video"
ModalityRadar Modality = "radar"
ModalityContact Modality = "contact"
ModalityButton Modality = "button"
ModalityWearable Modality = "wearable"
ModalityOther Modality = "other"
)
type Capability string
const (
CapabilityVideoCapture Capability = "video_capture"
CapabilityAudioCapture Capability = "audio_capture"
CapabilitySpatialRule Capability = "spatial_rule"
CapabilityTelemetry Capability = "telemetry"
)
type DesiredState string
const (
DesiredDisabled DesiredState = "disabled"
DesiredEnabled DesiredState = "enabled"
)
type ActualState string
const (
ActualPending ActualState = "pending"
ActualOnline ActualState = "online"
ActualOffline ActualState = "offline"
ActualFailed ActualState = "failed"
)
type Site struct {
TenantID string
ID string
Name string
MaxVideoChannels int
}
func (s *Site) ApplyDefaults() {
if s.MaxVideoChannels == 0 {
s.MaxVideoChannels = DefaultVideoChannels
}
}
func (s Site) Validate() error {
if strings.TrimSpace(s.TenantID) == "" || strings.TrimSpace(s.ID) == "" {
return errors.New("tenant ID and site ID are required")
}
if s.MaxVideoChannels < 1 || s.MaxVideoChannels > MaximumVideoChannels {
return fmt.Errorf("max video channels must be between 1 and %d", MaximumVideoChannels)
}
return nil
}
type Device struct {
ID string
TenantID string
SiteID string
AreaID string
SerialNumber string
Name string
Modality Modality
Capabilities []Capability
DesiredState DesiredState
ActualState ActualState
EndpointRef string
CredentialRef string
ProfileToken string
PathName string
Generation int64
ResourceVersion int64
QuotaSourceVersion int64
AreaPolicySourceVersion int64
CreatedAt time.Time
UpdatedAt time.Time
}
func (d Device) Validate() error {
if strings.TrimSpace(d.ID) == "" || strings.TrimSpace(d.TenantID) == "" || strings.TrimSpace(d.SiteID) == "" {
return errors.New("device ID, tenant ID and site ID are required")
}
if strings.TrimSpace(d.SerialNumber) == "" || strings.TrimSpace(d.Name) == "" {
return errors.New("serial number and device name are required")
}
if !validModality(d.Modality) {
return fmt.Errorf("unsupported modality %q", d.Modality)
}
if d.DesiredState != DesiredEnabled && d.DesiredState != DesiredDisabled {
return fmt.Errorf("unsupported desired state %q", d.DesiredState)
}
if d.ActualState != ActualPending && d.ActualState != ActualOnline && d.ActualState != ActualOffline && d.ActualState != ActualFailed {
return fmt.Errorf("unsupported actual state %q", d.ActualState)
}
if d.DesiredState == DesiredEnabled && d.HasCapability(CapabilityVideoCapture) {
if strings.TrimSpace(d.EndpointRef) == "" || strings.TrimSpace(d.PathName) == "" {
return errors.New("enabled video devices require endpoint ref and path name")
}
}
if d.EndpointRef != "" {
endpoint, err := url.Parse(d.EndpointRef)
if err != nil || endpoint.Scheme == "" {
return errors.New("endpoint ref must be an absolute URI")
}
if endpoint.User != nil {
return errors.New("endpoint ref must not contain credentials")
}
}
if d.PathName != "" && !validPathName(d.PathName) {
return errors.New("path name must contain safe ASCII segments")
}
seen := make(map[Capability]struct{}, len(d.Capabilities))
for _, capability := range d.Capabilities {
if !validCapability(capability) {
return fmt.Errorf("unsupported capability %q", capability)
}
if _, ok := seen[capability]; ok {
return fmt.Errorf("duplicate capability %q", capability)
}
seen[capability] = struct{}{}
}
return nil
}
func (d Device) HasCapability(capability Capability) bool {
return slices.Contains(d.Capabilities, capability)
}
func (d Device) ConsumesVideoChannel() bool {
return d.DesiredState == DesiredEnabled && d.HasCapability(CapabilityVideoCapture)
}
func validModality(value Modality) bool {
return slices.Contains([]Modality{ModalityVideo, ModalityRadar, ModalityContact, ModalityButton, ModalityWearable, ModalityOther}, value)
}
func validCapability(value Capability) bool {
return slices.Contains([]Capability{CapabilityVideoCapture, CapabilityAudioCapture, CapabilitySpatialRule, CapabilityTelemetry}, value)
}
func validPathName(value string) bool {
if strings.HasPrefix(value, "/") || strings.HasSuffix(value, "/") || strings.Contains(value, "//") || strings.Contains(value, "..") {
return false
}
for _, character := range value {
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') || strings.ContainsRune("-_/.", character) {
continue
}
return false
}
return value != ""
}
type QuotaExceededError struct {
TenantID string
SiteID string
Limit int
}
func (e *QuotaExceededError) Error() string {
return fmt.Sprintf("video channel quota exceeded for site %s/%s (limit %d)", e.TenantID, e.SiteID, e.Limit)
}
+30
View File
@@ -0,0 +1,30 @@
package device
import "testing"
func TestDeviceRejectsCredentialsInEndpointReference(t *testing.T) {
t.Parallel()
value := validVideoDevice()
value.EndpointRef = "http://" + "user" + ":" + "redacted" + "@camera.invalid/onvif"
if err := value.Validate(); err == nil {
t.Fatal("expected endpoint credentials to be rejected")
}
}
func TestDeviceRejectsUnsafeMediaPath(t *testing.T) {
t.Parallel()
value := validVideoDevice()
value.PathName = "tenant/../another-camera"
if err := value.Validate(); err == nil {
t.Fatal("expected unsafe path name to be rejected")
}
}
func validVideoDevice() Device {
return Device{
ID: "camera", TenantID: "tenant", SiteID: "site", SerialNumber: "serial", Name: "Camera",
Modality: ModalityVideo, Capabilities: []Capability{CapabilityVideoCapture},
DesiredState: DesiredEnabled, ActualState: ActualPending,
EndpointRef: "onvif://camera", PathName: "sense/tenant/site/camera",
}
}
+189
View File
@@ -0,0 +1,189 @@
// Package metrics exposes a deliberately small, low-cardinality Prometheus
// surface without adding a runtime dependency. Tenant, Site, device and Path
// identifiers never become labels.
package metrics
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync/atomic"
"time"
)
func GenerateInstanceID() (string, error) {
value := make([]byte, 12)
if _, err := rand.Read(value); err != nil {
return "", errors.New("generate Sense instance identifier")
}
return "ins_" + hex.EncodeToString(value), nil
}
type Registry struct {
instanceID string
version string
reconcileRunOK atomic.Uint64
reconcileRunError atomic.Uint64
reconcileItemOK atomic.Uint64
reconcileItemError atomic.Uint64
reconcileItemLost atomic.Uint64
reconcileDurationNS atomic.Uint64
reconcileTotal atomic.Int64
reconcileUnconverged atomic.Int64
orphanScanOK atomic.Uint64
orphanScanError atomic.Uint64
orphanObserved atomic.Int64
orphanOwnedStale atomic.Int64
orphanUnowned atomic.Int64
orphanBlockedRatio atomic.Uint64
orphanBlockedAge atomic.Uint64
orphanBlockedScope atomic.Uint64
orphanDeleted atomic.Uint64
orphanDeleteFailed atomic.Uint64
}
func New(instanceID, version string) *Registry {
return &Registry{instanceID: instanceID, version: version}
}
func (r *Registry) ObserveReconcileRun(err error, duration time.Duration) {
if err == nil {
r.reconcileRunOK.Add(1)
} else {
r.reconcileRunError.Add(1)
}
if duration < 0 {
duration = 0
}
r.reconcileDurationNS.Store(uint64(duration))
}
func (r *Registry) ObserveReconcileItem(result string) {
switch result {
case "success":
r.reconcileItemOK.Add(1)
case "lease_lost":
r.reconcileItemLost.Add(1)
default:
r.reconcileItemError.Add(1)
}
}
func (r *Registry) SetConvergence(total, unconverged int) {
r.reconcileTotal.Store(int64(total))
r.reconcileUnconverged.Store(int64(unconverged))
}
func (r *Registry) ObserveOrphanScan(observed, ownedStale, unowned int, err error) {
if err == nil {
r.orphanScanOK.Add(1)
r.orphanObserved.Store(int64(observed))
r.orphanOwnedStale.Store(int64(ownedStale))
r.orphanUnowned.Store(int64(unowned))
} else {
r.orphanScanError.Add(1)
}
}
func (r *Registry) ObserveOrphanCleanupBlocked(reason string) {
switch reason {
case "snapshot_expired":
r.orphanBlockedAge.Add(1)
case "ratio_exceeded":
r.orphanBlockedRatio.Add(1)
default:
r.orphanBlockedScope.Add(1)
}
}
func (r *Registry) ObserveOrphanCleanup(deleted, failed int) {
if deleted > 0 {
r.orphanDeleted.Add(uint64(deleted))
}
if failed > 0 {
r.orphanDeleteFailed.Add(uint64(failed))
}
}
func (r *Registry) Handler() http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
writer.Header().Set("Cache-Control", "no-store")
_, _ = writer.Write([]byte(r.render()))
})
}
func (r *Registry) render() string {
var output strings.Builder
writeMetric(&output, "sense_build_info", "Sense process build and instance information.",
fmt.Sprintf("{instance_id=%s,version=%s}", quoteLabel(r.instanceID), quoteLabel(r.version)), 1)
writeMetric(&output, "sense_reconcile_runs_total", "Reconciliation runs by result.",
`{result="success"}`, r.reconcileRunOK.Load())
writeSample(&output, "sense_reconcile_runs_total", `{result="error"}`, r.reconcileRunError.Load())
writeMetric(&output, "sense_reconcile_items_total", "Reconciliation items by fixed result.",
`{result="success"}`, r.reconcileItemOK.Load())
writeSample(&output, "sense_reconcile_items_total", `{result="error"}`, r.reconcileItemError.Load())
writeSample(&output, "sense_reconcile_items_total", `{result="lease_lost"}`, r.reconcileItemLost.Load())
writeMetric(&output, "sense_reconcile_last_duration_seconds", "Duration of the last reconciliation run.",
"", float64(r.reconcileDurationNS.Load())/float64(time.Second))
writeMetric(&output, "sense_reconcile_devices", "Enabled video devices by convergence state.",
`{state="total"}`, r.reconcileTotal.Load())
writeSample(&output, "sense_reconcile_devices", `{state="unconverged"}`, r.reconcileUnconverged.Load())
writeMetric(&output, "sense_orphan_scan_runs_total", "MediaMTX orphan scans by result.",
`{result="success"}`, r.orphanScanOK.Load())
writeSample(&output, "sense_orphan_scan_runs_total", `{result="error"}`, r.orphanScanError.Load())
writeMetric(&output, "sense_orphan_paths", "Path counts from the last successful orphan scan.",
`{classification="observed"}`, r.orphanObserved.Load())
writeSample(&output, "sense_orphan_paths", `{classification="owned_stale"}`, r.orphanOwnedStale.Load())
writeSample(&output, "sense_orphan_paths", `{classification="unowned"}`, r.orphanUnowned.Load())
writeMetric(&output, "sense_orphan_cleanup_blocked_total", "Orphan cleanups blocked by a fixed safety reason.",
`{reason="ratio_exceeded"}`, r.orphanBlockedRatio.Load())
writeSample(&output, "sense_orphan_cleanup_blocked_total", `{reason="snapshot_expired"}`, r.orphanBlockedAge.Load())
writeSample(&output, "sense_orphan_cleanup_blocked_total", `{reason="scope_changed"}`, r.orphanBlockedScope.Load())
writeMetric(&output, "sense_orphan_cleanup_items_total", "Owned stale path cleanup results.",
`{result="deleted"}`, r.orphanDeleted.Load())
writeSample(&output, "sense_orphan_cleanup_items_total", `{result="failed"}`, r.orphanDeleteFailed.Load())
return output.String()
}
func writeMetric(builder *strings.Builder, name, help, labels string, value any) {
metricType := "gauge"
if strings.HasSuffix(name, "_total") {
metricType = "counter"
}
builder.WriteString("# HELP ")
builder.WriteString(name)
builder.WriteByte(' ')
builder.WriteString(help)
builder.WriteByte('\n')
builder.WriteString("# TYPE ")
builder.WriteString(name)
builder.WriteByte(' ')
builder.WriteString(metricType)
builder.WriteByte('\n')
writeSample(builder, name, labels, value)
}
func writeSample(builder *strings.Builder, name, labels string, value any) {
builder.WriteString(name)
builder.WriteString(labels)
builder.WriteByte(' ')
switch typed := value.(type) {
case float64:
builder.WriteString(strconv.FormatFloat(typed, 'f', 6, 64))
default:
builder.WriteString(fmt.Sprint(typed))
}
builder.WriteByte('\n')
}
func quoteLabel(value string) string {
replacer := strings.NewReplacer(`\`, `\\`, "\n", `\n`, `"`, `\"`)
return `"` + replacer.Replace(value) + `"`
}
+84
View File
@@ -0,0 +1,84 @@
package metrics
import (
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
func TestHandlerUsesOnlyFixedLowCardinalityLabels(t *testing.T) {
t.Parallel()
registry := New("ins_test-1", "v-test")
registry.ObserveReconcileRun(nil, 1500*time.Millisecond)
registry.ObserveReconcileItem("success")
registry.ObserveReconcileItem("lease_lost")
registry.SetConvergence(16, 2)
registry.ObserveOrphanScan(20, 1, 3, nil)
registry.ObserveOrphanCleanupBlocked("ratio_exceeded")
registry.ObserveOrphanCleanup(1, 1)
request := httptest.NewRequest("GET", "/metrics", nil)
response := httptest.NewRecorder()
registry.Handler().ServeHTTP(response, request)
body := response.Body.String()
for _, expected := range []string{
`sense_build_info{instance_id="ins_test-1",version="v-test"} 1`,
`sense_reconcile_items_total{result="lease_lost"} 1`,
`sense_reconcile_devices{state="unconverged"} 2`,
`sense_orphan_paths{classification="owned_stale"} 1`,
`sense_orphan_cleanup_blocked_total{reason="ratio_exceeded"} 1`,
} {
if !strings.Contains(body, expected) {
t.Fatalf("metrics output lacks %q:\n%s", expected, body)
}
}
for _, forbidden := range []string{
"tenant_id", "site_id", "device_id", "path_name", "rtsp://", "credential",
} {
if strings.Contains(body, forbidden) {
t.Fatalf("high-cardinality or secret-bearing label appeared: %q", forbidden)
}
}
if response.Header().Get("Cache-Control") != "no-store" {
t.Fatal("metrics responses must not be cached")
}
}
func TestRegistryIsSafeForConcurrentObservers(t *testing.T) {
t.Parallel()
registry := New("ins_concurrent", "dev")
var wait sync.WaitGroup
for index := 0; index < 32; index++ {
wait.Add(1)
go func() {
defer wait.Done()
for count := 0; count < 100; count++ {
registry.ObserveReconcileItem("success")
registry.SetConvergence(128, count%17)
}
}()
}
wait.Wait()
response := httptest.NewRecorder()
registry.Handler().ServeHTTP(response, httptest.NewRequest("GET", "/metrics", nil))
if !strings.Contains(response.Body.String(), `sense_reconcile_items_total{result="success"} 3200`) {
t.Fatalf("concurrent counter lost updates: %s", response.Body.String())
}
}
func TestGenerateInstanceIDShape(t *testing.T) {
t.Parallel()
first, err := GenerateInstanceID()
if err != nil {
t.Fatal(err)
}
second, err := GenerateInstanceID()
if err != nil {
t.Fatal(err)
}
if len(first) != 28 || !strings.HasPrefix(first, "ins_") || first == second {
t.Fatalf("unexpected instance identifiers: %q %q", first, second)
}
}
+200
View File
@@ -0,0 +1,200 @@
package mtx
import (
"context"
"errors"
"fmt"
"net/http"
"sort"
"strings"
mediamtxapi "yovision/sense/internal/mtx/generated"
)
var ErrPathNotFound = errors.New("MediaMTX path not found")
type APIError struct {
Operation string
StatusCode int
}
func (e *APIError) Error() string {
return fmt.Sprintf("MediaMTX %s failed with HTTP status %d", e.Operation, e.StatusCode)
}
type PathConfig struct {
Name string
Source string
}
type pathAPI interface {
ConfigPathsAddWithResponse(context.Context, string, mediamtxapi.ConfigPathsAddJSONRequestBody, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsAddResponse, error)
ConfigPathsGetWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsGetResponse, error)
ConfigPathsPatchWithResponse(context.Context, string, mediamtxapi.ConfigPathsPatchJSONRequestBody, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsPatchResponse, error)
ConfigPathsDeleteWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsDeleteResponse, error)
ConfigPathsListWithResponse(context.Context, *mediamtxapi.ConfigPathsListParams, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsListResponse, error)
PathsGetWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.PathsGetResponse, error)
}
// ListPathNames enumerates only configuration names. Sources are deliberately
// discarded so inventory and orphan reports cannot expose stream URIs.
func (c *Client) ListPathNames(ctx context.Context) ([]string, error) {
const (
itemsPerPage = 100
maxPages = 1000
)
result := make(map[string]struct{})
seenPages := make(map[string]struct{})
for page := 0; page < maxPages; page++ {
pageValue, limitValue := page, itemsPerPage
response, err := c.api.ConfigPathsListWithResponse(ctx, &mediamtxapi.ConfigPathsListParams{
Page: &pageValue, ItemsPerPage: &limitValue,
})
if err != nil {
return nil, fmt.Errorf("MediaMTX list paths transport: %w", err)
}
if response.StatusCode() != http.StatusOK || response.JSON200 == nil ||
response.JSON200.PageCount == nil || response.JSON200.Items == nil {
return nil, &APIError{Operation: "list paths", StatusCode: response.StatusCode()}
}
pageCount := *response.JSON200.PageCount
if pageCount < 0 || pageCount > maxPages {
return nil, &APIError{Operation: "list paths pagination", StatusCode: response.StatusCode()}
}
pageNames := make([]string, 0, len(*response.JSON200.Items))
for _, item := range *response.JSON200.Items {
if item.Name == nil || strings.TrimSpace(*item.Name) == "" {
return nil, &APIError{Operation: "list paths response", StatusCode: response.StatusCode()}
}
pageNames = append(pageNames, *item.Name)
result[*item.Name] = struct{}{}
}
sort.Strings(pageNames)
signature := strings.Join(pageNames, "\x00")
if page > 0 && signature != "" {
if _, duplicate := seenPages[signature]; duplicate {
return nil, &APIError{Operation: "list paths repeated page", StatusCode: response.StatusCode()}
}
}
seenPages[signature] = struct{}{}
if int64(page+1) >= pageCount {
values := make([]string, 0, len(result))
for name := range result {
values = append(values, name)
}
sort.Strings(values)
return values, nil
}
}
return nil, &APIError{Operation: "list paths page limit", StatusCode: http.StatusOK}
}
type Client struct {
api pathAPI
}
func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
options := make([]mediamtxapi.ClientOption, 0, 1)
if httpClient != nil {
options = append(options, mediamtxapi.WithHTTPClient(httpClient))
}
generated, err := mediamtxapi.NewClientWithResponses(strings.TrimRight(baseURL, "/"), options...)
if err != nil {
return nil, fmt.Errorf("create MediaMTX client: %w", err)
}
return &Client{api: generated}, nil
}
func newClientWithAPI(api pathAPI) *Client {
return &Client{api: api}
}
func (c *Client) CreatePath(ctx context.Context, name, source string) error {
response, err := c.api.ConfigPathsAddWithResponse(ctx, name, mediamtxapi.PathConf{Source: &source})
if err != nil {
return fmt.Errorf("MediaMTX create path transport: %w", err)
}
if response.StatusCode() != http.StatusOK {
return &APIError{Operation: "create path", StatusCode: response.StatusCode()}
}
return nil
}
func (c *Client) GetPath(ctx context.Context, name string) (PathConfig, error) {
response, err := c.api.ConfigPathsGetWithResponse(ctx, name)
if err != nil {
return PathConfig{}, fmt.Errorf("MediaMTX read path transport: %w", err)
}
if response.StatusCode() == http.StatusNotFound {
return PathConfig{}, ErrPathNotFound
}
if response.StatusCode() != http.StatusOK || response.JSON200 == nil {
return PathConfig{}, &APIError{Operation: "read path", StatusCode: response.StatusCode()}
}
result := PathConfig{Name: name}
if response.JSON200.Name != nil {
result.Name = *response.JSON200.Name
}
if response.JSON200.Source != nil {
result.Source = *response.JSON200.Source
}
return result, nil
}
func (c *Client) DeletePath(ctx context.Context, name string) error {
response, err := c.api.ConfigPathsDeleteWithResponse(ctx, name)
if err != nil {
return fmt.Errorf("MediaMTX delete path transport: %w", err)
}
if response.StatusCode() == http.StatusNotFound {
// Deletion is an idempotent convergence operation. A missing exact path
// already satisfies the disabled desired state.
return nil
}
if response.StatusCode() != http.StatusOK {
return &APIError{Operation: "delete path", StatusCode: response.StatusCode()}
}
return nil
}
// EnsurePath converges one desired path. It never enumerates or deletes orphans.
func (c *Client) EnsurePath(ctx context.Context, name, source string) (bool, error) {
current, err := c.GetPath(ctx, name)
if errors.Is(err, ErrPathNotFound) {
if err := c.CreatePath(ctx, name, source); err != nil {
return false, err
}
return true, nil
}
if err != nil {
return false, err
}
if current.Source == source {
return false, nil
}
response, err := c.api.ConfigPathsPatchWithResponse(ctx, name, mediamtxapi.PathConf{Source: &source})
if err != nil {
return false, fmt.Errorf("MediaMTX patch path transport: %w", err)
}
if response.StatusCode() != http.StatusOK {
return false, &APIError{Operation: "patch path", StatusCode: response.StatusCode()}
}
return true, nil
}
func (c *Client) PathReady(ctx context.Context, name string) (bool, error) {
response, err := c.api.PathsGetWithResponse(ctx, name)
if err != nil {
return false, fmt.Errorf("MediaMTX probe path transport: %w", err)
}
if response.StatusCode() == http.StatusNotFound {
return false, ErrPathNotFound
}
if response.StatusCode() != http.StatusOK || response.JSON200 == nil {
return false, &APIError{Operation: "probe path", StatusCode: response.StatusCode()}
}
if response.JSON200.Online == nil || response.JSON200.Available == nil {
return false, &APIError{Operation: "probe path response", StatusCode: response.StatusCode()}
}
return *response.JSON200.Online && *response.JSON200.Available, nil
}
+232
View File
@@ -0,0 +1,232 @@
package mtx
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sort"
"strconv"
"strings"
"sync"
"testing"
)
type fakeMediaMTX struct {
mu sync.Mutex
paths map[string]string
mutations int
}
func (f *fakeMediaMTX) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
writer.Header().Set("Content-Type", "application/json")
if request.URL.Path == "/v3/config/paths/list" {
page, _ := strconv.Atoi(request.URL.Query().Get("page"))
limit, _ := strconv.Atoi(request.URL.Query().Get("itemsPerPage"))
if limit <= 0 {
limit = 100
}
names := make([]string, 0, len(f.paths))
for name := range f.paths {
names = append(names, name)
}
sort.Strings(names)
start := page * limit
if start > len(names) {
start = len(names)
}
end := start + limit
if end > len(names) {
end = len(names)
}
items := make([]map[string]any, 0, end-start)
for _, name := range names[start:end] {
items = append(items, map[string]any{"name": name})
}
pageCount := 0
if len(names) > 0 {
pageCount = (len(names) + limit - 1) / limit
}
_ = json.NewEncoder(writer).Encode(map[string]any{
"itemCount": len(names), "pageCount": pageCount, "items": items,
})
return
}
prefixes := map[string]string{
"/v3/config/paths/get/": "get",
"/v3/config/paths/add/": "add",
"/v3/config/paths/patch/": "patch",
"/v3/config/paths/delete/": "delete",
"/v3/paths/get/": "runtime",
}
for prefix, operation := range prefixes {
if !strings.HasPrefix(request.URL.Path, prefix) {
continue
}
name := strings.TrimPrefix(request.URL.Path, prefix)
source, exists := f.paths[name]
switch operation {
case "get":
if !exists {
http.Error(writer, `{"error":"not found"}`, http.StatusNotFound)
return
}
_ = json.NewEncoder(writer).Encode(map[string]any{"name": name, "source": source})
case "add", "patch":
var body struct {
Source string `json:"source"`
}
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
http.Error(writer, `{}`, http.StatusBadRequest)
return
}
f.paths[name] = body.Source
f.mutations++
_, _ = writer.Write([]byte(`{}`))
case "delete":
if !exists {
http.Error(writer, `{"error":"not found"}`, http.StatusNotFound)
return
}
delete(f.paths, name)
f.mutations++
_, _ = writer.Write([]byte(`{}`))
case "runtime":
if !exists {
http.Error(writer, `{"error":"not found"}`, http.StatusNotFound)
return
}
_, _ = writer.Write([]byte(`{"online":true,"available":true}`))
}
return
}
http.NotFound(writer, request)
}
func TestListPathNamesUsesPaginationAndDoesNotReturnSources(t *testing.T) {
t.Parallel()
paths := make(map[string]string)
for index := 0; index < 205; index++ {
paths[fmt.Sprintf("camera-%03d", index)] = fmt.Sprintf("rtsp://secret.invalid/%d", index)
}
fake := &fakeMediaMTX{paths: paths}
server := httptest.NewServer(fake)
defer server.Close()
client, err := NewClient(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
values, err := client.ListPathNames(context.Background())
if err != nil {
t.Fatal(err)
}
if len(values) != 205 || values[0] != "camera-000" || values[204] != "camera-204" {
t.Fatalf("unexpected path inventory: len=%d first=%q last=%q", len(values), values[0], values[len(values)-1])
}
for _, value := range values {
if strings.Contains(value, "rtsp") || strings.Contains(value, "secret") {
t.Fatalf("source leaked from path inventory: %q", value)
}
}
}
func TestListPathNamesRejectsRepeatedPage(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "application/json")
if request.URL.Path != "/v3/config/paths/list" {
http.NotFound(writer, request)
return
}
_, _ = writer.Write([]byte(`{"itemCount":2,"pageCount":2,"items":[{"name":"same"}]}`))
}))
defer server.Close()
client, err := NewClient(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
if _, err := client.ListPathNames(context.Background()); err == nil ||
!strings.Contains(err.Error(), "repeated page") {
t.Fatalf("repeated pagination was accepted: %v", err)
}
}
func TestGeneratedClientCreateReadDeleteMapping(t *testing.T) {
t.Parallel()
fake := &fakeMediaMTX{paths: make(map[string]string)}
server := httptest.NewServer(fake)
defer server.Close()
client, err := NewClient(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if err := client.CreatePath(ctx, "camera-1", "rtsp://media.invalid/camera-1"); err != nil {
t.Fatal(err)
}
path, err := client.GetPath(ctx, "camera-1")
if err != nil {
t.Fatal(err)
}
if path.Source != "rtsp://media.invalid/camera-1" {
t.Fatalf("unexpected source mapping: %+v", path)
}
if err := client.DeletePath(ctx, "camera-1"); err != nil {
t.Fatal(err)
}
if _, err := client.GetPath(ctx, "camera-1"); err != ErrPathNotFound {
t.Fatalf("expected not found after delete, got %v", err)
}
}
func TestEnsurePathIsIdempotentAndCanPatch(t *testing.T) {
t.Parallel()
fake := &fakeMediaMTX{paths: make(map[string]string)}
server := httptest.NewServer(fake)
defer server.Close()
client, err := NewClient(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
changed, err := client.EnsurePath(ctx, "camera-2", "rtsp://media.invalid/first")
if err != nil || !changed {
t.Fatalf("first ensure: changed=%v err=%v", changed, err)
}
changed, err = client.EnsurePath(ctx, "camera-2", "rtsp://media.invalid/first")
if err != nil || changed {
t.Fatalf("second ensure must be idempotent: changed=%v err=%v", changed, err)
}
changed, err = client.EnsurePath(ctx, "camera-2", "rtsp://media.invalid/second")
if err != nil || !changed {
t.Fatalf("changed source must patch: changed=%v err=%v", changed, err)
}
if fake.mutations != 2 {
t.Fatalf("expected create + patch, got %d mutations", fake.mutations)
}
ready, err := client.PathReady(ctx, "camera-2")
if err != nil || !ready {
t.Fatalf("runtime probe: ready=%v err=%v", ready, err)
}
}
func TestAPIErrorDoesNotLeakSource(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprint(writer, `{"error":"upstream included a secret"}`)
}))
defer server.Close()
client, err := NewClient(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
secretSource := "rtsp://" + "user" + ":" + "redacted" + "@camera.invalid/live"
err = client.CreatePath(context.Background(), "camera", secretSource)
if err == nil || strings.Contains(err.Error(), secretSource) || strings.Contains(err.Error(), "secret") {
t.Fatalf("error must be redacted, got %v", err)
}
}
+5
View File
@@ -0,0 +1,5 @@
// Package mtx wraps the generated MediaMTX control API client.
package mtx
// The input is the official API document vendored from the frozen MediaMTX tag.
//go:generate go tool oapi-codegen -config oapi-codegen.yaml ../../api/vendor/mediamtx-v1.19.3.openapi.yaml
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
package: mediamtxapi
output: generated/client.gen.go
generate:
models: true
client: true
output-options:
skip-prune: false
+62
View File
@@ -0,0 +1,62 @@
package onvif
import (
"fmt"
"net/url"
"os"
"regexp"
"strings"
)
type Credentials struct {
ONVIFUsername string
ONVIFPassword string
RTSPUsername string
RTSPPassword string
}
type CredentialProvider interface {
Resolve(reference string) (Credentials, error)
}
type EnvCredentials struct {
LookupEnv func(string) (string, bool)
}
var credentialKey = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
func (p EnvCredentials) Resolve(reference string) (Credentials, error) {
parsed, err := url.Parse(reference)
if err != nil || parsed.Scheme != "env" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return Credentials{}, fmt.Errorf("credential reference must use env://<key>")
}
key := strings.Trim(strings.TrimSpace(parsed.Host+parsed.Path), "/")
if !credentialKey.MatchString(key) {
return Credentials{}, fmt.Errorf("credential reference contains an invalid key")
}
lookup := p.LookupEnv
if lookup == nil {
lookup = os.LookupEnv
}
prefix := "SENSE_CREDENTIAL_" + strings.ToUpper(strings.ReplaceAll(key, "-", "_"))
read := func(suffix string) string {
value, _ := lookup(prefix + suffix)
return value
}
result := Credentials{
ONVIFUsername: read("_ONVIF_USERNAME"),
ONVIFPassword: read("_ONVIF_PASSWORD"),
RTSPUsername: read("_RTSP_USERNAME"),
RTSPPassword: read("_RTSP_PASSWORD"),
}
if result.ONVIFUsername == "" || result.ONVIFPassword == "" {
return Credentials{}, fmt.Errorf("ONVIF credentials are not configured for reference")
}
if result.RTSPUsername == "" {
result.RTSPUsername = result.ONVIFUsername
}
if result.RTSPPassword == "" {
result.RTSPPassword = result.ONVIFPassword
}
return result, nil
}
+126
View File
@@ -0,0 +1,126 @@
package onvif
import (
"context"
"encoding/json"
"fmt"
"os"
"sync"
"time"
)
type FakeScenario struct {
Result ProbeResult `json:"result"`
ProbeError ErrorCode `json:"probe_error,omitempty"`
ClockError ErrorCode `json:"clock_error,omitempty"`
DelayMillis int `json:"delay_millis,omitempty"`
}
type fakeFixture struct {
Scenarios map[string]FakeScenario `json:"scenarios"`
}
// Fake is deterministic and intended only for tests and offline development.
type Fake struct {
mu sync.Mutex
scenarios map[string]FakeScenario
probeCalls map[string]int
clockSyncCalls map[string]int
}
func NewFake(scenarios map[string]FakeScenario) *Fake {
copyOfScenarios := make(map[string]FakeScenario, len(scenarios))
for key, value := range scenarios {
copyOfScenarios[key] = value
}
return &Fake{
scenarios: copyOfScenarios, probeCalls: make(map[string]int), clockSyncCalls: make(map[string]int),
}
}
func LoadFakeFixture(path string) (*Fake, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read ONVIF fixture: %w", err)
}
var fixture fakeFixture
if err := json.Unmarshal(data, &fixture); err != nil {
return nil, fmt.Errorf("decode ONVIF fixture: %w", err)
}
return NewFake(fixture.Scenarios), nil
}
func (f *Fake) Probe(ctx context.Context, target Target) (ProbeResult, error) {
scenario, ok := f.scenario(target.EndpointRef)
if !ok {
return ProbeResult{}, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("fixture endpoint is not configured")}
}
if err := waitForFakeDelay(ctx, scenario.DelayMillis); err != nil {
return ProbeResult{}, &Error{Code: ErrorTimeout, Err: err}
}
f.mu.Lock()
f.probeCalls[target.EndpointRef]++
f.mu.Unlock()
if scenario.ProbeError != "" {
return ProbeResult{}, &Error{Code: scenario.ProbeError, Err: fmt.Errorf("fixture probe failure")}
}
if scenario.Result.StreamURI == "" || len(scenario.Result.Profiles) == 0 {
return ProbeResult{}, &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("fixture lacks profile or stream URI")}
}
return scenario.Result, nil
}
func (f *Fake) SetSystemDateAndTime(ctx context.Context, target Target, _ time.Time) error {
scenario, ok := f.scenario(target.EndpointRef)
if !ok {
return &Error{Code: ErrorUnavailable, Err: fmt.Errorf("fixture endpoint is not configured")}
}
if err := waitForFakeDelay(ctx, scenario.DelayMillis); err != nil {
return &Error{Code: ErrorTimeout, Err: err}
}
f.mu.Lock()
f.clockSyncCalls[target.EndpointRef]++
f.mu.Unlock()
if scenario.ClockError != "" {
return &Error{Code: scenario.ClockError, Err: fmt.Errorf("fixture clock failure")}
}
return nil
}
func (f *Fake) ProbeCalls(endpointRef string) int {
f.mu.Lock()
defer f.mu.Unlock()
return f.probeCalls[endpointRef]
}
func (f *Fake) ClockSyncCalls(endpointRef string) int {
f.mu.Lock()
defer f.mu.Unlock()
return f.clockSyncCalls[endpointRef]
}
func (f *Fake) scenario(endpointRef string) (FakeScenario, bool) {
f.mu.Lock()
defer f.mu.Unlock()
scenario, ok := f.scenarios[endpointRef]
return scenario, ok
}
func waitForFakeDelay(ctx context.Context, milliseconds int) error {
if milliseconds <= 0 {
select {
case <-ctx.Done():
return ctx.Err()
default:
return nil
}
}
timer := time.NewTimer(time.Duration(milliseconds) * time.Millisecond)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
+56
View File
@@ -0,0 +1,56 @@
package onvif
import (
"context"
"path/filepath"
"testing"
"time"
)
func TestFakeMapsProfilesStreamAndClockSync(t *testing.T) {
t.Parallel()
fake, err := LoadFakeFixture(filepath.Join("testdata", "scenarios.json"))
if err != nil {
t.Fatal(err)
}
target := Target{EndpointRef: "onvif://camera-ok", CredentialRef: "secret://camera-ok"}
result, err := fake.Probe(context.Background(), target)
if err != nil {
t.Fatal(err)
}
if len(result.Profiles) != 2 || result.StreamURI != "rtsp://media.invalid/camera-ok" {
t.Fatalf("unexpected fixture mapping: %+v", result)
}
if err := fake.SetSystemDateAndTime(context.Background(), target, time.Now()); err != nil {
t.Fatal(err)
}
if fake.ProbeCalls(target.EndpointRef) != 1 || fake.ClockSyncCalls(target.EndpointRef) != 1 {
t.Fatal("expected one probe and one clock-sync call")
}
}
func TestFakeMapsAuthenticationFailure(t *testing.T) {
t.Parallel()
fake, err := LoadFakeFixture(filepath.Join("testdata", "scenarios.json"))
if err != nil {
t.Fatal(err)
}
_, err = fake.Probe(context.Background(), Target{EndpointRef: "onvif://camera-auth"})
if CodeOf(err) != ErrorAuthentication {
t.Fatalf("expected authentication error, got %v", err)
}
}
func TestFakeHonorsCancellationAsTimeout(t *testing.T) {
t.Parallel()
fake, err := LoadFakeFixture(filepath.Join("testdata", "scenarios.json"))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
defer cancel()
_, err = fake.Probe(ctx, Target{EndpointRef: "onvif://camera-slow"})
if CodeOf(err) != ErrorTimeout {
t.Fatalf("expected timeout error, got %v", err)
}
}
+342
View File
@@ -0,0 +1,342 @@
package onvif
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
deviceNamespace = "http://www.onvif.org/ver10/device/wsdl"
mediaNamespace = "http://www.onvif.org/ver10/media/wsdl"
)
type HTTPOptions struct {
RTSPRewriteHost string
RTSPRewritePort int
StripRTSPQuery bool
}
type HTTPAdapter struct {
credentials CredentialProvider
client *http.Client
options HTTPOptions
now func() time.Time
random io.Reader
}
func NewHTTPAdapter(credentials CredentialProvider, client *http.Client, options HTTPOptions) *HTTPAdapter {
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
return &HTTPAdapter{
credentials: credentials,
client: client,
options: options,
now: time.Now,
random: rand.Reader,
}
}
func (a *HTTPAdapter) Probe(ctx context.Context, target Target) (ProbeResult, error) {
endpoint, credentials, err := a.target(target)
if err != nil {
return ProbeResult{}, err
}
infoBody, err := a.call(ctx, endpoint, deviceNamespace+"/GetDeviceInformation",
`<tds:GetDeviceInformation xmlns:tds="`+deviceNamespace+`"/>`, credentials)
if err != nil {
return ProbeResult{}, err
}
var info deviceInformationEnvelope
if err := xml.Unmarshal(infoBody, &info); err != nil {
return ProbeResult{}, invalidResponse("decode device information")
}
servicesBody, err := a.call(ctx, endpoint, deviceNamespace+"/GetServices",
`<tds:GetServices xmlns:tds="`+deviceNamespace+`"><tds:IncludeCapability>false</tds:IncludeCapability></tds:GetServices>`, credentials)
if err != nil {
return ProbeResult{}, err
}
var services servicesEnvelope
if err := xml.Unmarshal(servicesBody, &services); err != nil {
return ProbeResult{}, invalidResponse("decode services")
}
mediaEndpoint, err := externalMediaEndpoint(endpoint, services.Body.Response.Services)
if err != nil {
return ProbeResult{}, err
}
profilesBody, err := a.call(ctx, mediaEndpoint, mediaNamespace+"/GetProfiles",
`<trt:GetProfiles xmlns:trt="`+mediaNamespace+`"/>`, credentials)
if err != nil {
return ProbeResult{}, err
}
var profilesResponse profilesEnvelope
if err := xml.Unmarshal(profilesBody, &profilesResponse); err != nil {
return ProbeResult{}, invalidResponse("decode profiles")
}
profiles := make([]Profile, 0, len(profilesResponse.Body.Response.Profiles))
selectedToken := ""
for _, value := range profilesResponse.Body.Response.Profiles {
video := value.VideoEncoder != nil
profiles = append(profiles, Profile{Token: value.Token, Name: value.Name, VideoEncoder: video})
if selectedToken == "" && video && value.Token != "" {
selectedToken = value.Token
}
}
if selectedToken == "" {
return ProbeResult{}, invalidResponse("no video profile")
}
streamRequest := `<trt:GetStreamUri xmlns:trt="` + mediaNamespace + `" xmlns:tt="http://www.onvif.org/ver10/schema">` +
`<trt:StreamSetup><tt:Stream>RTP-Unicast</tt:Stream><tt:Transport><tt:Protocol>RTSP</tt:Protocol></tt:Transport></trt:StreamSetup>` +
`<trt:ProfileToken>` + escapeXML(selectedToken) + `</trt:ProfileToken></trt:GetStreamUri>`
streamBody, err := a.call(ctx, mediaEndpoint, mediaNamespace+"/GetStreamUri", streamRequest, credentials)
if err != nil {
return ProbeResult{}, err
}
var streamResponse streamURIEnvelope
if err := xml.Unmarshal(streamBody, &streamResponse); err != nil {
return ProbeResult{}, invalidResponse("decode stream URI")
}
streamURI, err := a.rewriteStreamURI(endpoint, streamResponse.Body.Response.MediaURI.URI, credentials)
if err != nil {
return ProbeResult{}, err
}
return ProbeResult{
Manufacturer: info.Body.Response.Manufacturer,
Model: info.Body.Response.Model,
FirmwareVersion: info.Body.Response.FirmwareVersion,
SerialNumber: info.Body.Response.SerialNumber,
Profiles: profiles,
StreamURI: streamURI,
}, nil
}
func (a *HTTPAdapter) SetSystemDateAndTime(ctx context.Context, target Target, value time.Time) error {
endpoint, credentials, err := a.target(target)
if err != nil {
return err
}
utc := value.UTC()
body := `<tds:SetSystemDateAndTime xmlns:tds="` + deviceNamespace + `" xmlns:tt="http://www.onvif.org/ver10/schema">` +
`<tds:DateTimeType>Manual</tds:DateTimeType><tds:DaylightSavings>false</tds:DaylightSavings>` +
`<tds:UTCDateTime><tt:Time><tt:Hour>` + strconv.Itoa(utc.Hour()) + `</tt:Hour><tt:Minute>` + strconv.Itoa(utc.Minute()) +
`</tt:Minute><tt:Second>` + strconv.Itoa(utc.Second()) + `</tt:Second></tt:Time><tt:Date><tt:Year>` + strconv.Itoa(utc.Year()) +
`</tt:Year><tt:Month>` + strconv.Itoa(int(utc.Month())) + `</tt:Month><tt:Day>` + strconv.Itoa(utc.Day()) +
`</tt:Day></tt:Date></tds:UTCDateTime></tds:SetSystemDateAndTime>`
_, err = a.call(ctx, endpoint, deviceNamespace+"/SetSystemDateAndTime", body, credentials)
return err
}
func (a *HTTPAdapter) target(target Target) (*url.URL, Credentials, error) {
endpoint, err := url.Parse(target.EndpointRef)
if err != nil || endpoint.Host == "" || endpoint.User != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") {
return nil, Credentials{}, invalidResponse("invalid ONVIF endpoint")
}
credentials, err := a.credentials.Resolve(target.CredentialRef)
if err != nil {
return nil, Credentials{}, &Error{Code: ErrorAuthentication, Err: err}
}
return endpoint, credentials, nil
}
func (a *HTTPAdapter) call(ctx context.Context, endpoint *url.URL, action, body string, credentials Credentials) ([]byte, error) {
nonce := make([]byte, 20)
if _, err := io.ReadFull(a.random, nonce); err != nil {
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("create authentication nonce")}
}
created := a.now().UTC().Format("2006-01-02T15:04:05Z")
digestInput := append(append(append([]byte{}, nonce...), []byte(created)...), []byte(credentials.ONVIFPassword)...)
digest := sha1.Sum(digestInput)
envelope := `<?xml version="1.0" encoding="UTF-8"?>` +
`<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">` +
`<s:Header><wsse:Security s:mustUnderstand="1"><wsse:UsernameToken><wsse:Username>` + escapeXML(credentials.ONVIFUsername) +
`</wsse:Username><wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">` +
base64.StdEncoding.EncodeToString(digest[:]) + `</wsse:Password><wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">` +
base64.StdEncoding.EncodeToString(nonce) + `</wsse:Nonce><wsu:Created>` + created +
`</wsu:Created></wsse:UsernameToken></wsse:Security></s:Header><s:Body>` + body + `</s:Body></s:Envelope>`
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(envelope))
if err != nil {
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("create ONVIF request")}
}
request.Header.Set("Content-Type", `application/soap+xml; charset=utf-8; action="`+action+`"`)
response, err := a.client.Do(request)
if err != nil {
code := ErrorUnavailable
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
code = ErrorTimeout
}
return nil, &Error{Code: code, Err: fmt.Errorf("ONVIF transport failed")}
}
defer response.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
if err != nil {
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("read ONVIF response")}
}
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
return nil, &Error{Code: ErrorAuthentication, Err: fmt.Errorf("ONVIF authorization failed")}
}
if fault := soapFault(responseBody); fault != "" {
code := ErrorInvalidReply
lower := strings.ToLower(fault)
if strings.Contains(lower, "authoriz") || strings.Contains(lower, "notauthorized") {
code = ErrorAuthentication
}
return nil, &Error{Code: code, Err: fmt.Errorf("ONVIF SOAP fault")}
}
if response.StatusCode != http.StatusOK {
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("ONVIF returned HTTP status %d", response.StatusCode)}
}
return responseBody, nil
}
func externalMediaEndpoint(deviceEndpoint *url.URL, services []service) (*url.URL, error) {
for _, value := range services {
if value.Namespace != mediaNamespace || value.XAddr == "" {
continue
}
mediaEndpoint, err := url.Parse(value.XAddr)
if err != nil || mediaEndpoint.Host == "" {
return nil, invalidResponse("invalid media service address")
}
mediaEndpoint.Scheme = deviceEndpoint.Scheme
mediaEndpoint.Host = deviceEndpoint.Host
mediaEndpoint.User = nil
return mediaEndpoint, nil
}
return nil, invalidResponse("media service is unavailable")
}
func (a *HTTPAdapter) rewriteStreamURI(deviceEndpoint *url.URL, raw string, credentials Credentials) (string, error) {
stream, err := url.Parse(raw)
if err != nil || stream.Host == "" || (stream.Scheme != "rtsp" && stream.Scheme != "rtsps") {
return "", invalidResponse("invalid stream URI")
}
host := a.options.RTSPRewriteHost
if host == "" {
host = deviceEndpoint.Hostname()
}
port := a.options.RTSPRewritePort
if port == 0 {
if parsedPort := stream.Port(); parsedPort != "" {
value, parseErr := strconv.Atoi(parsedPort)
if parseErr != nil {
return "", invalidResponse("invalid stream port")
}
port = value
}
}
if port > 0 {
stream.Host = net.JoinHostPort(host, strconv.Itoa(port))
} else {
stream.Host = host
}
stream.User = url.UserPassword(credentials.RTSPUsername, credentials.RTSPPassword)
if a.options.StripRTSPQuery {
stream.RawQuery = ""
stream.ForceQuery = false
}
return stream.String(), nil
}
func invalidResponse(message string) error {
return &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("%s", message)}
}
func escapeXML(value string) string {
var buffer bytes.Buffer
_ = xml.EscapeText(&buffer, []byte(value))
return buffer.String()
}
func soapFault(body []byte) string {
decoder := xml.NewDecoder(bytes.NewReader(body))
inFault := false
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
return ""
}
if err != nil {
return ""
}
switch value := token.(type) {
case xml.StartElement:
if value.Name.Local == "Fault" {
inFault = true
}
if inFault && (value.Name.Local == "Text" || value.Name.Local == "faultstring") {
var message string
if decoder.DecodeElement(&message, &value) == nil {
return message
}
}
case xml.EndElement:
if value.Name.Local == "Fault" {
return "SOAP fault"
}
}
}
}
type deviceInformationEnvelope struct {
Body struct {
Response struct {
Manufacturer string `xml:"Manufacturer"`
Model string `xml:"Model"`
FirmwareVersion string `xml:"FirmwareVersion"`
SerialNumber string `xml:"SerialNumber"`
} `xml:"GetDeviceInformationResponse"`
} `xml:"Body"`
}
type service struct {
Namespace string `xml:"Namespace"`
XAddr string `xml:"XAddr"`
}
type servicesEnvelope struct {
Body struct {
Response struct {
Services []service `xml:"Service"`
} `xml:"GetServicesResponse"`
} `xml:"Body"`
}
type profileResponse struct {
Token string `xml:"token,attr"`
Name string `xml:"Name"`
VideoEncoder *struct{} `xml:"VideoEncoderConfiguration"`
}
type profilesEnvelope struct {
Body struct {
Response struct {
Profiles []profileResponse `xml:"Profiles"`
} `xml:"GetProfilesResponse"`
} `xml:"Body"`
}
type streamURIEnvelope struct {
Body struct {
Response struct {
MediaURI struct {
URI string `xml:"Uri"`
} `xml:"MediaUri"`
} `xml:"GetStreamUriResponse"`
} `xml:"Body"`
}
+136
View File
@@ -0,0 +1,136 @@
package onvif
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
type staticCredentials struct {
value Credentials
err error
}
func (s staticCredentials) Resolve(string) (Credentials, error) {
return s.value, s.err
}
func TestHTTPAdapterDiscoversMediaAndRewritesNATStream(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if !strings.Contains(request.Header.Get("Content-Type"), "action=") {
t.Fatal("SOAP action is required")
}
body := ""
switch {
case strings.Contains(request.Header.Get("Content-Type"), "GetDeviceInformation"):
body = `<tds:GetDeviceInformationResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl"><tds:Manufacturer>HIKVISION</tds:Manufacturer><tds:Model>camera</tds:Model><tds:FirmwareVersion>v1</tds:FirmwareVersion><tds:SerialNumber>serial</tds:SerialNumber></tds:GetDeviceInformationResponse>`
case strings.Contains(request.Header.Get("Content-Type"), "GetServices"):
body = `<tds:GetServicesResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl"><tds:Service><tds:Namespace>http://www.onvif.org/ver10/media/wsdl</tds:Namespace><tds:XAddr>http://192.0.2.10/onvif/Media</tds:XAddr></tds:Service></tds:GetServicesResponse>`
case strings.Contains(request.Header.Get("Content-Type"), "GetProfiles"):
body = `<trt:GetProfilesResponse xmlns:trt="http://www.onvif.org/ver10/media/wsdl"><trt:Profiles token="main"><trt:Name>Main</trt:Name><trt:VideoEncoderConfiguration/></trt:Profiles></trt:GetProfilesResponse>`
case strings.Contains(request.Header.Get("Content-Type"), "GetStreamUri"):
body = `<trt:GetStreamUriResponse xmlns:trt="http://www.onvif.org/ver10/media/wsdl"><trt:MediaUri><trt:Uri>rtsp://192.0.2.10:554/Streaming/Channels/101?transportmode=unicast&amp;profile=Profile_1</trt:Uri></trt:MediaUri></trt:GetStreamUriResponse>`
default:
http.Error(writer, "unexpected action", http.StatusBadRequest)
return
}
writer.Header().Set("Content-Type", "application/soap+xml")
_, _ = fmt.Fprintf(writer, `<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body>%s</s:Body></s:Envelope>`, body)
}))
defer server.Close()
credentials := Credentials{
ONVIFUsername: "onvif-user", ONVIFPassword: "onvif-password",
RTSPUsername: "rtsp-user", RTSPPassword: "rtsp-password",
}
adapter := NewHTTPAdapter(staticCredentials{value: credentials}, server.Client(), HTTPOptions{
RTSPRewriteHost: "127.0.0.1", RTSPRewritePort: 10554, StripRTSPQuery: true,
})
result, err := adapter.Probe(context.Background(), Target{
EndpointRef: server.URL + "/onvif/device_service", CredentialRef: "env://camera",
})
if err != nil {
t.Fatal(err)
}
if result.Manufacturer != "HIKVISION" || result.Model != "camera" || len(result.Profiles) != 1 {
t.Fatalf("unexpected probe result: %+v", result)
}
stream, err := url.Parse(result.StreamURI)
if err != nil {
t.Fatal(err)
}
if stream.Host != "127.0.0.1:10554" || stream.Path != "/Streaming/Channels/101" || stream.RawQuery != "" {
t.Fatalf("unexpected rewritten stream address: host=%s path=%s", stream.Host, stream.Path)
}
password, _ := stream.User.Password()
if stream.User.Username() != credentials.RTSPUsername || password != credentials.RTSPPassword {
t.Fatal("RTSP credentials were not injected")
}
}
func TestHTTPAdapterPreservesRTSPQueryByDefault(t *testing.T) {
t.Parallel()
adapter := NewHTTPAdapter(staticCredentials{}, nil, HTTPOptions{})
streamURI, err := adapter.rewriteStreamURI(
&url.URL{Scheme: "http", Host: "camera.example:8008"},
"rtsp://192.0.2.10:554/live?profile=main",
Credentials{RTSPUsername: "user", RTSPPassword: "secret"},
)
if err != nil {
t.Fatal(err)
}
stream, err := url.Parse(streamURI)
if err != nil {
t.Fatal(err)
}
if stream.RawQuery != "profile=main" {
t.Fatalf("RTSP query was unexpectedly changed: %q", stream.RawQuery)
}
}
func TestHTTPAdapterMapsAuthorizationFaultWithoutLeakingSecret(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusBadRequest)
_, _ = writer.Write([]byte(`<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><s:Fault><s:Reason><s:Text>The action requires authorization</s:Text></s:Reason></s:Fault></s:Body></s:Envelope>`))
}))
defer server.Close()
secret := "not-for-errors"
adapter := NewHTTPAdapter(staticCredentials{value: Credentials{
ONVIFUsername: "user", ONVIFPassword: secret, RTSPUsername: "user", RTSPPassword: secret,
}}, server.Client(), HTTPOptions{})
_, err := adapter.Probe(context.Background(), Target{EndpointRef: server.URL, CredentialRef: "env://camera"})
if CodeOf(err) != ErrorAuthentication || strings.Contains(err.Error(), secret) {
t.Fatalf("expected redacted authentication error, got %v", err)
}
}
func TestEnvCredentialsAndDirectRTSPRouting(t *testing.T) {
t.Parallel()
values := map[string]string{
"SENSE_CREDENTIAL_CAMERA_ONVIF_USERNAME": "onvif",
"SENSE_CREDENTIAL_CAMERA_ONVIF_PASSWORD": "onvif-secret",
"SENSE_CREDENTIAL_CAMERA_RTSP_USERNAME": "rtsp",
"SENSE_CREDENTIAL_CAMERA_RTSP_PASSWORD": "rtsp-secret",
}
provider := EnvCredentials{LookupEnv: func(name string) (string, bool) {
value, ok := values[name]
return value, ok
}}
resolved, err := provider.Resolve("env://camera")
if err != nil || resolved.RTSPUsername != "rtsp" {
t.Fatalf("resolve credentials: %+v err=%v", resolved, err)
}
router := NewRouter(UnavailableAdapter{}, provider)
result, err := router.Probe(context.Background(), Target{
EndpointRef: "rtsp://127.0.0.1:8555/synthetic-1",
})
if err != nil || result.StreamURI != "rtsp://127.0.0.1:8555/synthetic-1" {
t.Fatalf("route direct RTSP: %+v err=%v", result, err)
}
}
+70
View File
@@ -0,0 +1,70 @@
// Package onvif defines the ONVIF boundary used by Sense.
package onvif
import (
"context"
"errors"
"fmt"
"time"
)
type Target struct {
// EndpointRef identifies a device endpoint but must not contain credentials.
EndpointRef string
// CredentialRef is an opaque secret-store reference, never a password.
CredentialRef string
}
type Profile struct {
Token string `json:"token"`
Name string `json:"name"`
VideoEncoder bool `json:"video_encoder"`
}
type ProbeResult struct {
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
FirmwareVersion string `json:"firmware_version"`
SerialNumber string `json:"serial_number"`
Profiles []Profile `json:"profiles"`
StreamURI string `json:"stream_uri"`
}
type Adapter interface {
Probe(ctx context.Context, target Target) (ProbeResult, error)
SetSystemDateAndTime(ctx context.Context, target Target, value time.Time) error
}
type ErrorCode string
const (
ErrorAuthentication ErrorCode = "authentication_failed"
ErrorTimeout ErrorCode = "timeout"
ErrorUnavailable ErrorCode = "unavailable"
ErrorInvalidReply ErrorCode = "invalid_response"
)
type Error struct {
Code ErrorCode
Err error
}
func (e *Error) Error() string {
if e.Err == nil {
return string(e.Code)
}
return fmt.Sprintf("%s: %v", e.Code, e.Err)
}
func (e *Error) Unwrap() error { return e.Err }
func CodeOf(err error) ErrorCode {
var onvifError *Error
if errors.As(err, &onvifError) {
return onvifError.Code
}
if errors.Is(err, context.DeadlineExceeded) {
return ErrorTimeout
}
return ErrorUnavailable
}
+50
View File
@@ -0,0 +1,50 @@
package onvif
import (
"context"
"fmt"
"net/url"
"time"
)
type Router struct {
camera Adapter
credentials CredentialProvider
}
func NewRouter(camera Adapter, credentials CredentialProvider) *Router {
return &Router{camera: camera, credentials: credentials}
}
func (r *Router) Probe(ctx context.Context, target Target) (ProbeResult, error) {
endpoint, err := url.Parse(target.EndpointRef)
if err != nil || endpoint.Host == "" || endpoint.User != nil {
return ProbeResult{}, &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("endpoint reference is invalid")}
}
switch endpoint.Scheme {
case "http", "https":
return r.camera.Probe(ctx, target)
case "rtsp", "rtsps":
if target.CredentialRef != "" {
credentials, resolveErr := r.credentials.Resolve(target.CredentialRef)
if resolveErr != nil {
return ProbeResult{}, &Error{Code: ErrorAuthentication, Err: resolveErr}
}
endpoint.User = url.UserPassword(credentials.RTSPUsername, credentials.RTSPPassword)
}
return ProbeResult{
Profiles: []Profile{{Token: "direct", Name: "direct", VideoEncoder: true}},
StreamURI: endpoint.String(),
}, nil
default:
return ProbeResult{}, &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("unsupported endpoint scheme")}
}
}
func (r *Router) SetSystemDateAndTime(ctx context.Context, target Target, value time.Time) error {
endpoint, err := url.Parse(target.EndpointRef)
if err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") {
return &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("clock sync requires an ONVIF endpoint")}
}
return r.camera.SetSystemDateAndTime(ctx, target, value)
}
+28
View File
@@ -0,0 +1,28 @@
{
"scenarios": {
"onvif://camera-ok": {
"result": {
"manufacturer": "YoVision Fixture",
"model": "Offline Camera",
"firmware_version": "0.0-fixture",
"serial_number": "REDACTED-001",
"profiles": [
{"token": "main", "name": "Main stream", "video_encoder": true},
{"token": "sub", "name": "Sub stream", "video_encoder": true}
],
"stream_uri": "rtsp://media.invalid/camera-ok"
}
},
"onvif://camera-auth": {
"probe_error": "authentication_failed",
"result": {}
},
"onvif://camera-slow": {
"delay_millis": 100,
"result": {
"profiles": [{"token": "main", "name": "Main stream", "video_encoder": true}],
"stream_uri": "rtsp://media.invalid/camera-slow"
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
package onvif
import (
"context"
"fmt"
"time"
)
// UnavailableAdapter keeps the process boundary explicit until T-006 supplies
// a real, whitelist-validated ONVIF adapter. It must not be mistaken for a
// compatibility implementation.
type UnavailableAdapter struct{}
func (UnavailableAdapter) Probe(context.Context, Target) (ProbeResult, error) {
return ProbeResult{}, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("real ONVIF adapter is not configured")}
}
func (UnavailableAdapter) SetSystemDateAndTime(context.Context, Target, time.Time) error {
return &Error{Code: ErrorUnavailable, Err: fmt.Errorf("real ONVIF adapter is not configured")}
}
+400
View File
@@ -0,0 +1,400 @@
// Package orphan reports MediaMTX configuration paths that do not match the
// current Sense ledger. Only paths with durable Sense ownership evidence can
// ever enter the controlled cleanup set.
package orphan
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math/big"
"regexp"
"sort"
"strings"
"time"
"yovision/sense/internal/metrics"
"yovision/sense/internal/store"
)
const (
defaultLeaseDuration = 30 * time.Second
defaultOperationTimeout = 20 * time.Second
reportTTL = 15 * time.Minute
maxCleanupItems = 128
crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
)
var (
ErrLeaseHeld = errors.New("orphan operation lease held")
ErrConfirmation = errors.New("orphan cleanup confirmation invalid")
ErrSnapshotExpired = errors.New("orphan scan snapshot expired")
ErrSafetyBlocked = errors.New("orphan cleanup safety gate blocked")
operatorIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
)
type Runtime interface {
ListPathNames(context.Context) ([]string, error)
DeletePath(context.Context, string) error
}
type Manager struct {
repository store.OrphanRepository
runtime Runtime
instanceID string
metrics *metrics.Registry
now func() time.Time
leaseDuration time.Duration
operationTimeout time.Duration
}
type CleanupResult struct {
ScanID string `json:"scan_id"`
Deleted int `json:"deleted"`
Failed int `json:"failed"`
}
func New(
repository store.OrphanRepository,
runtime Runtime,
instanceID string,
registry *metrics.Registry,
) *Manager {
return &Manager{
repository: repository, runtime: runtime, instanceID: instanceID, metrics: registry,
now: time.Now, leaseDuration: defaultLeaseDuration, operationTimeout: defaultOperationTimeout,
}
}
func (m *Manager) Report(ctx context.Context) (store.OrphanScan, error) {
now := m.now().UTC()
token, err := randomToken()
if err != nil {
return store.OrphanScan{}, err
}
acquired, err := m.repository.AcquireOperationalLease(
ctx, store.OperationalLeaseOrphanScan, m.instanceID, token, now, m.leaseDuration,
)
if err != nil {
m.observeScan(0, 0, 0, err)
return store.OrphanScan{}, err
}
if !acquired {
return store.OrphanScan{}, ErrLeaseHeld
}
saved := false
defer func() {
if saved {
return
}
releaseCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = m.repository.ReleaseOperationalLease(
releaseCtx, store.OperationalLeaseOrphanScan, m.instanceID, token, m.now().UTC(),
)
}()
operationCtx, cancel := context.WithTimeout(ctx, m.operationTimeout)
paths, err := m.runtime.ListPathNames(operationCtx)
cancel()
if err != nil {
m.observeScan(0, 0, 0, err)
return store.OrphanScan{}, fmt.Errorf("list MediaMTX paths: %w", err)
}
ownership, err := m.repository.ListMediaPathOwnership(ctx)
if err != nil {
m.observeScan(0, 0, 0, err)
return store.OrphanScan{}, err
}
findings, stale, unowned := classify(paths, ownership)
allowed, reason := safetyGate(stale, len(paths))
completedAt := m.now().UTC()
acquired, err = m.repository.AcquireOperationalLease(
ctx, store.OperationalLeaseOrphanScan, m.instanceID, token, completedAt, m.leaseDuration,
)
if err != nil || !acquired {
if err == nil {
err = store.ErrOperationalLeaseLost
}
m.observeScan(len(paths), stale, unowned, err)
return store.OrphanScan{}, err
}
id, err := newScanID(completedAt)
if err != nil {
m.observeScan(0, 0, 0, err)
return store.OrphanScan{}, err
}
scan := store.OrphanScan{
ID: id, InstanceID: m.instanceID, ObservedCount: len(paths),
OwnedStaleCount: stale, UnownedCount: unowned,
SafetyAllowed: allowed, SafetyReason: reason,
CompletedAt: completedAt, ExpiresAt: completedAt.Add(reportTTL), Findings: findings,
}
if err := m.repository.SaveOrphanScan(ctx, scan, m.instanceID, token); err != nil {
m.observeScan(len(paths), stale, unowned, err)
return store.OrphanScan{}, err
}
saved = true
m.observeScan(len(paths), stale, unowned, nil)
return scan, nil
}
func classify(
paths []string,
ownership []store.MediaPathOwnership,
) ([]store.OrphanFinding, int, int) {
history := make(map[string]store.MediaPathOwnership, len(ownership))
for _, value := range ownership {
history[value.PathName] = value
}
unique := make(map[string]struct{}, len(paths))
for _, path := range paths {
unique[path] = struct{}{}
}
ordered := make([]string, 0, len(unique))
for path := range unique {
ordered = append(ordered, path)
}
sort.Strings(ordered)
findings := make([]store.OrphanFinding, 0)
stale, unowned := 0, 0
for _, path := range ordered {
value, known := history[path]
switch {
case known && value.CurrentClaim:
continue
case known:
stale++
findings = append(findings, store.OrphanFinding{
PathName: path, Classification: store.OrphanOwnedStale, DeviceID: value.DeviceID,
})
default:
unowned++
findings = append(findings, store.OrphanFinding{
PathName: path, Classification: store.OrphanUnowned,
})
}
}
return findings, stale, unowned
}
func safetyGate(candidates, observed int) (bool, string) {
switch {
case candidates <= 0:
return false, "no_candidates"
case observed <= 0:
return false, "empty_inventory"
case candidates > maxCleanupItems:
return false, "scope_exceeded"
case candidates*100 > observed*10:
return false, "ratio_exceeded"
default:
return true, "allowed"
}
}
func (m *Manager) Apply(
ctx context.Context,
scanID, actorID, confirmation string,
) (CleanupResult, error) {
result := CleanupResult{ScanID: scanID}
if !operatorIDPattern.MatchString(actorID) || confirmation != "DELETE "+scanID {
m.blocked("scope_changed")
return result, ErrConfirmation
}
now := m.now().UTC()
token, err := randomToken()
if err != nil {
return result, err
}
acquired, err := m.repository.AcquireOperationalLease(
ctx, store.OperationalLeaseOrphanCleanup, m.instanceID, token, now, m.leaseDuration,
)
if err != nil {
return result, err
}
if !acquired {
return result, ErrLeaseHeld
}
defer func() {
releaseCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = m.repository.ReleaseOperationalLease(
releaseCtx, store.OperationalLeaseOrphanCleanup, m.instanceID, token, m.now().UTC(),
)
}()
scan, err := m.repository.GetOrphanScan(ctx, scanID)
if err != nil {
m.blocked("scope_changed")
return result, err
}
if !scan.ExpiresAt.After(now) {
m.blocked("snapshot_expired")
return result, ErrSnapshotExpired
}
if !scan.SafetyAllowed {
m.blocked(scan.SafetyReason)
return result, fmt.Errorf("%w: %s", ErrSafetyBlocked, scan.SafetyReason)
}
operationCtx, cancel := context.WithTimeout(ctx, m.operationTimeout)
paths, err := m.runtime.ListPathNames(operationCtx)
cancel()
if err != nil {
return result, fmt.Errorf("refresh MediaMTX path inventory: %w", err)
}
ownership, err := m.repository.ListMediaPathOwnership(ctx)
if err != nil {
return result, err
}
candidates := cleanupCandidates(scan, paths, ownership)
if len(candidates) == 0 {
return result, nil
}
allowed, reason := safetyGate(len(candidates), len(uniqueNames(paths)))
if !allowed {
m.blocked(reason)
return result, fmt.Errorf("%w: %s", ErrSafetyBlocked, reason)
}
var failures []error
for _, path := range candidates {
now = m.now().UTC()
acquired, err = m.repository.AcquireOperationalLease(
ctx, store.OperationalLeaseOrphanCleanup, m.instanceID, token, now, m.leaseDuration,
)
if err != nil || !acquired {
if err == nil {
err = ErrLeaseHeld
}
failures = append(failures, err)
break
}
itemCtx, itemCancel := context.WithTimeout(ctx, m.operationTimeout)
deleteErr := m.runtime.DeletePath(itemCtx, path)
itemCancel()
status, code := "deleted", ""
if deleteErr != nil {
status, code = "failed", "media_error"
result.Failed++
failures = append(failures, fmt.Errorf("delete owned stale path: %w", deleteErr))
} else {
result.Deleted++
}
if err := m.repository.RecordOrphanCleanup(
ctx, scanID, path, actorID, status, code, m.now().UTC(),
); err != nil {
failures = append(failures, err)
break
}
}
if m.metrics != nil {
m.metrics.ObserveOrphanCleanup(result.Deleted, result.Failed)
}
return result, errors.Join(failures...)
}
func cleanupCandidates(
scan store.OrphanScan,
paths []string,
ownership []store.MediaPathOwnership,
) []string {
runtime := uniqueNames(paths)
history := make(map[string]store.MediaPathOwnership, len(ownership))
for _, value := range ownership {
history[value.PathName] = value
}
values := make([]string, 0)
for _, finding := range scan.Findings {
if finding.Classification != store.OrphanOwnedStale || finding.Deleted {
continue
}
_, present := runtime[finding.PathName]
owner, known := history[finding.PathName]
if present && known && !owner.CurrentClaim && owner.DeviceID == finding.DeviceID {
values = append(values, finding.PathName)
}
}
sort.Strings(values)
return values
}
func uniqueNames(values []string) map[string]struct{} {
result := make(map[string]struct{}, len(values))
for _, value := range values {
result[value] = struct{}{}
}
return result
}
func (m *Manager) Run(ctx context.Context, interval time.Duration, report func(error)) {
run := func() {
_, err := m.Report(ctx)
if err != nil && !errors.Is(err, ErrLeaseHeld) && ctx.Err() == nil && report != nil {
report(err)
}
}
run()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
run()
}
}
}
func (m *Manager) observeScan(observed, stale, unowned int, err error) {
if m.metrics != nil {
m.metrics.ObserveOrphanScan(observed, stale, unowned, err)
}
}
func (m *Manager) blocked(reason string) {
if m.metrics != nil {
m.metrics.ObserveOrphanCleanupBlocked(reason)
}
}
func randomToken() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", errors.New("generate fencing token")
}
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
result := make([]byte, 22)
number := new(big.Int).SetBytes(value)
base := big.NewInt(int64(len(alphabet)))
remainder := new(big.Int)
for index := len(result) - 1; index >= 0; index-- {
number.QuoRem(number, base, remainder)
result[index] = alphabet[remainder.Int64()]
}
return string(result), nil
}
func newScanID(now time.Time) (string, error) {
value := make([]byte, 16)
milliseconds := uint64(now.UTC().UnixMilli())
value[0], value[1], value[2] = byte(milliseconds>>40), byte(milliseconds>>32), byte(milliseconds>>24)
value[3], value[4], value[5] = byte(milliseconds>>16), byte(milliseconds>>8), byte(milliseconds)
if _, err := rand.Read(value[6:]); err != nil {
return "", errors.New("generate orphan scan identifier")
}
number := new(big.Int).SetBytes(value)
base := big.NewInt(32)
remainder := new(big.Int)
encoded := make([]byte, 26)
for index := len(encoded) - 1; index >= 0; index-- {
number.QuoRem(number, base, remainder)
encoded[index] = crockford[remainder.Int64()]
}
return "scan_" + string(encoded), nil
}
func Confirmation(scanID string) string {
return strings.Join([]string{"DELETE", scanID}, " ")
}
+272
View File
@@ -0,0 +1,272 @@
package orphan
import (
"context"
"errors"
"sort"
"sync"
"testing"
"time"
"yovision/sense/internal/store"
)
type fakeLease struct {
owner, token string
expires time.Time
}
type fakeRepository struct {
mu sync.Mutex
leases map[string]fakeLease
ownership []store.MediaPathOwnership
scans map[string]store.OrphanScan
deleted map[string]map[string]bool
}
func newFakeRepository() *fakeRepository {
return &fakeRepository{
leases: make(map[string]fakeLease), scans: make(map[string]store.OrphanScan),
deleted: make(map[string]map[string]bool),
}
}
func (f *fakeRepository) AcquireOperationalLease(
_ context.Context,
name, owner, token string,
now time.Time,
duration time.Duration,
) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
current, exists := f.leases[name]
if exists && current.expires.After(now) && (current.owner != owner || current.token != token) {
return false, nil
}
f.leases[name] = fakeLease{owner: owner, token: token, expires: now.Add(duration)}
return true, nil
}
func (f *fakeRepository) ReleaseOperationalLease(
_ context.Context,
name, owner, token string,
now time.Time,
) error {
f.mu.Lock()
defer f.mu.Unlock()
current := f.leases[name]
if current.owner == owner && current.token == token {
current.expires = now
f.leases[name] = current
}
return nil
}
func (f *fakeRepository) ListMediaPathOwnership(context.Context) ([]store.MediaPathOwnership, error) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]store.MediaPathOwnership(nil), f.ownership...), nil
}
func (f *fakeRepository) SaveOrphanScan(
_ context.Context,
scan store.OrphanScan,
owner, token string,
) error {
f.mu.Lock()
defer f.mu.Unlock()
lease := f.leases[store.OperationalLeaseOrphanScan]
if lease.owner != owner || lease.token != token || !lease.expires.After(scan.CompletedAt) {
return store.ErrOperationalLeaseLost
}
f.scans[scan.ID] = cloneScan(scan)
lease.expires = scan.CompletedAt
f.leases[store.OperationalLeaseOrphanScan] = lease
return nil
}
func (f *fakeRepository) GetOrphanScan(_ context.Context, id string) (store.OrphanScan, error) {
f.mu.Lock()
defer f.mu.Unlock()
scan, exists := f.scans[id]
if !exists {
return store.OrphanScan{}, store.ErrOrphanScanNotFound
}
result := cloneScan(scan)
for index := range result.Findings {
result.Findings[index].Deleted = f.deleted[id][result.Findings[index].PathName]
}
return result, nil
}
func (f *fakeRepository) RecordOrphanCleanup(
_ context.Context,
scanID, pathName, _, status, _ string,
_ time.Time,
) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.deleted[scanID] == nil {
f.deleted[scanID] = make(map[string]bool)
}
if status == "deleted" {
f.deleted[scanID][pathName] = true
}
return nil
}
func cloneScan(value store.OrphanScan) store.OrphanScan {
value.Findings = append([]store.OrphanFinding(nil), value.Findings...)
return value
}
type fakeRuntime struct {
paths map[string]bool
fail map[string]bool
deleted []string
}
func (f *fakeRuntime) ListPathNames(context.Context) ([]string, error) {
values := make([]string, 0, len(f.paths))
for path, present := range f.paths {
if present {
values = append(values, path)
}
}
sort.Strings(values)
return values, nil
}
func (f *fakeRuntime) DeletePath(_ context.Context, path string) error {
if f.fail[path] {
return errors.New("redacted media failure")
}
delete(f.paths, path)
f.deleted = append(f.deleted, path)
return nil
}
func TestSafetyGateUsesExactTenPercentBoundary(t *testing.T) {
t.Parallel()
if allowed, reason := safetyGate(1, 10); !allowed || reason != "allowed" {
t.Fatalf("exact 10%% boundary was rejected: %v %s", allowed, reason)
}
if allowed, reason := safetyGate(1, 9); allowed || reason != "ratio_exceeded" {
t.Fatalf("more than 10%% was accepted: %v %s", allowed, reason)
}
if allowed, reason := safetyGate(129, 2000); allowed || reason != "scope_exceeded" {
t.Fatalf("129-item scope was accepted: %v %s", allowed, reason)
}
}
func TestReportClassifiesOwnershipAndApplyNeverDeletesUnowned(t *testing.T) {
repository := newFakeRepository()
repository.ownership = []store.MediaPathOwnership{
{PathName: "owned-stale", DeviceID: "old-device"},
{PathName: "owned-current", DeviceID: "live-device", CurrentClaim: true},
}
runtime := &fakeRuntime{paths: map[string]bool{
"owned-stale": true, "owned-current": true,
"unowned-1": true, "unowned-2": true, "unowned-3": true, "unowned-4": true,
"unowned-5": true, "unowned-6": true, "unowned-7": true, "unowned-8": true,
}, fail: make(map[string]bool)}
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
manager := New(repository, runtime, "ins_test", nil)
manager.now = func() time.Time { return now }
scan, err := manager.Report(context.Background())
if err != nil {
t.Fatal(err)
}
if scan.ObservedCount != 10 || scan.OwnedStaleCount != 1 || scan.UnownedCount != 8 || !scan.SafetyAllowed {
t.Fatalf("unexpected report: %+v", scan)
}
result, err := manager.Apply(
context.Background(), scan.ID, "operator-1", Confirmation(scan.ID),
)
if err != nil {
t.Fatal(err)
}
if result.Deleted != 1 || len(runtime.deleted) != 1 || runtime.deleted[0] != "owned-stale" {
t.Fatalf("cleanup escaped owned stale set: result=%+v deleted=%v", result, runtime.deleted)
}
for _, path := range []string{"owned-current", "unowned-1", "unowned-8"} {
if !runtime.paths[path] {
t.Fatalf("cleanup deleted protected path %q", path)
}
}
}
func TestApplyBlocksExpiredOrOversizedSnapshotBeforeDelete(t *testing.T) {
repository := newFakeRepository()
repository.ownership = []store.MediaPathOwnership{{PathName: "stale", DeviceID: "old"}}
runtime := &fakeRuntime{paths: map[string]bool{"stale": true}, fail: make(map[string]bool)}
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
manager := New(repository, runtime, "ins_test", nil)
manager.now = func() time.Time { return now }
scan, err := manager.Report(context.Background())
if err != nil {
t.Fatal(err)
}
if scan.SafetyAllowed || scan.SafetyReason != "ratio_exceeded" {
t.Fatalf("single-path 100%% cleanup was not blocked: %+v", scan)
}
if _, err := manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID)); !errors.Is(err, ErrSafetyBlocked) {
t.Fatalf("expected ratio gate, got %v", err)
}
if len(runtime.deleted) != 0 {
t.Fatal("ratio-blocked cleanup mutated MediaMTX")
}
for index := 0; index < 9; index++ {
runtime.paths[string(rune('a'+index))] = true
}
scan, err = manager.Report(context.Background())
if err != nil || !scan.SafetyAllowed {
t.Fatalf("expected a fresh 1/10 executable report: %+v %v", scan, err)
}
now = now.Add(reportTTL)
if _, err := manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID)); !errors.Is(err, ErrSnapshotExpired) {
t.Fatalf("expected expired snapshot, got %v", err)
}
if len(runtime.deleted) != 0 {
t.Fatal("expired cleanup mutated MediaMTX")
}
}
func TestApplyIsRetryableAndCannotExpandBeyondSnapshot(t *testing.T) {
repository := newFakeRepository()
repository.ownership = []store.MediaPathOwnership{
{PathName: "stale-a", DeviceID: "old-a"},
{PathName: "stale-b", DeviceID: "old-b"},
}
runtime := &fakeRuntime{paths: make(map[string]bool), fail: map[string]bool{"stale-b": true}}
runtime.paths["stale-a"], runtime.paths["stale-b"] = true, true
for index := 0; index < 18; index++ {
runtime.paths[string(rune(0x100+index))] = true
}
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
manager := New(repository, runtime, "ins_test", nil)
manager.now = func() time.Time { return now }
scan, err := manager.Report(context.Background())
if err != nil || !scan.SafetyAllowed {
t.Fatalf("expected 2/20 report: %+v %v", scan, err)
}
repository.ownership = append(repository.ownership,
store.MediaPathOwnership{PathName: "new-stale", DeviceID: "new-old"})
runtime.paths["new-stale"] = true
result, err := manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID))
if err == nil || result.Deleted != 1 || result.Failed != 1 {
t.Fatalf("expected one partial failure: %+v %v", result, err)
}
if !runtime.paths["new-stale"] {
t.Fatal("cleanup expanded beyond the approved snapshot")
}
runtime.fail["stale-b"] = false
result, err = manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID))
if err != nil || result.Deleted != 1 || result.Failed != 0 {
t.Fatalf("failed item was not retryable: %+v %v", result, err)
}
if !runtime.paths["new-stale"] {
t.Fatal("retry expanded beyond the approved snapshot")
}
}
+86
View File
@@ -0,0 +1,86 @@
// Package probe maps MediaMTX runtime path health into the Sense actual state.
package probe
import (
"context"
"errors"
"fmt"
"time"
"yovision/sense/internal/device"
)
const defaultBatchSize = 128
type Repository interface {
ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error)
UpdateActualState(ctx context.Context, id string, state device.ActualState, now time.Time) error
RequestReconcile(ctx context.Context, id string, now time.Time) error
}
type RuntimePaths interface {
PathReady(ctx context.Context, name string) (bool, error)
}
type Checker struct {
repository Repository
media RuntimePaths
now func() time.Time
batchSize int
}
func New(repository Repository, media RuntimePaths) *Checker {
return &Checker{repository: repository, media: media, now: time.Now, batchSize: defaultBatchSize}
}
func (c *Checker) RunOnce(ctx context.Context) error {
devices, err := c.repository.ListEnabledVideoDevices(ctx, c.batchSize)
if err != nil {
return fmt.Errorf("list probe candidates: %w", err)
}
var runErrors []error
for _, value := range devices {
if err := ctx.Err(); err != nil {
return err
}
ready, probeErr := c.media.PathReady(ctx, value.PathName)
if ctx.Err() != nil {
return ctx.Err()
}
state := device.ActualOffline
if probeErr == nil && ready {
state = device.ActualOnline
}
now := c.now().UTC()
if probeErr != nil {
if requestErr := c.repository.RequestReconcile(ctx, value.ID, now); requestErr != nil {
runErrors = append(runErrors, fmt.Errorf("request device %s reconciliation: %w", value.ID, requestErr))
}
}
if updateErr := c.repository.UpdateActualState(ctx, value.ID, state, now); updateErr != nil {
runErrors = append(runErrors, fmt.Errorf("update device %s health: %w", value.ID, updateErr))
}
if probeErr != nil {
runErrors = append(runErrors, fmt.Errorf("probe device %s: %w", value.ID, probeErr))
}
}
return errors.Join(runErrors...)
}
func (c *Checker) Run(ctx context.Context, interval time.Duration, report func(error)) {
if err := c.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
report(err)
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := c.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
report(err)
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
package probe
import (
"context"
"errors"
"testing"
"time"
"yovision/sense/internal/device"
)
type fakeRepository struct {
devices []device.Device
states map[string]device.ActualState
requested map[string]int
}
func (f *fakeRepository) RequestReconcile(_ context.Context, id string, _ time.Time) error {
if f.requested == nil {
f.requested = make(map[string]int)
}
f.requested[id]++
return nil
}
func (f *fakeRepository) ListEnabledVideoDevices(context.Context, int) ([]device.Device, error) {
return f.devices, nil
}
func (f *fakeRepository) UpdateActualState(_ context.Context, id string, state device.ActualState, _ time.Time) error {
if f.states == nil {
f.states = make(map[string]device.ActualState)
}
f.states[id] = state
return nil
}
type fakeRuntime struct {
ready map[string]bool
errors map[string]error
}
func (f fakeRuntime) PathReady(_ context.Context, name string) (bool, error) {
return f.ready[name], f.errors[name]
}
func TestCheckerMapsReadyAndUnavailablePaths(t *testing.T) {
t.Parallel()
repository := &fakeRepository{devices: []device.Device{
{ID: "online", PathName: "online"}, {ID: "offline", PathName: "offline"},
}}
checker := New(repository, fakeRuntime{
ready: map[string]bool{"online": true}, errors: map[string]error{"offline": errors.New("unavailable")},
})
err := checker.RunOnce(context.Background())
if err == nil {
t.Fatal("probe transport error must remain observable")
}
if repository.states["online"] != device.ActualOnline || repository.states["offline"] != device.ActualOffline {
t.Fatalf("unexpected actual states: %+v", repository.states)
}
if repository.requested["offline"] != 1 || repository.requested["online"] != 0 {
t.Fatalf("unexpected reconcile requests: %+v", repository.requested)
}
}
+262
View File
@@ -0,0 +1,262 @@
// Package reconcile converges MediaMTX paths from the database desired state.
package reconcile
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/url"
"time"
"yovision/sense/internal/device"
"yovision/sense/internal/metrics"
"yovision/sense/internal/onvif"
"yovision/sense/internal/store"
)
const defaultBatchSize = 128
type Repository interface {
ClaimDueReconcile(context.Context, store.ReconcileClaim) ([]store.ReconcileCandidate, error)
RenewReconcileLease(context.Context, string, string, string, time.Time, time.Duration) (bool, error)
CompleteReconcile(context.Context, string, int64, string, string, time.Time) error
FailReconcile(context.Context, string, int, time.Time, string, string, string, time.Time) error
ConvergenceSnapshot(context.Context) (store.ConvergenceSnapshot, error)
}
type MediaPaths interface {
EnsurePath(ctx context.Context, name, source string) (bool, error)
DeletePath(ctx context.Context, name string) error
}
type Reconciler struct {
repository Repository
discovery onvif.Adapter
media MediaPaths
now func() time.Time
baseBackoff time.Duration
maxBackoff time.Duration
batchSize int
instanceID string
leaseDuration time.Duration
operationTimeout time.Duration
metrics *metrics.Registry
}
type Options struct {
InstanceID string
LeaseDuration time.Duration
OperationTimeout time.Duration
Metrics *metrics.Registry
}
func New(repository Repository, discovery onvif.Adapter, media MediaPaths) *Reconciler {
return NewWithOptions(repository, discovery, media, Options{})
}
func NewWithOptions(
repository Repository,
discovery onvif.Adapter,
media MediaPaths,
options Options,
) *Reconciler {
if options.InstanceID == "" {
options.InstanceID = "single"
}
if options.LeaseDuration <= 0 {
options.LeaseDuration = 30 * time.Second
}
if options.OperationTimeout <= 0 {
options.OperationTimeout = 20 * time.Second
}
if options.OperationTimeout >= options.LeaseDuration {
options.OperationTimeout = options.LeaseDuration / 2
}
return &Reconciler{
repository: repository, discovery: discovery, media: media,
now: time.Now, baseBackoff: time.Second, maxBackoff: time.Minute, batchSize: defaultBatchSize,
instanceID: options.InstanceID, leaseDuration: options.LeaseDuration,
operationTimeout: options.OperationTimeout, metrics: options.Metrics,
}
}
func (r *Reconciler) RunOnce(ctx context.Context) (runErr error) {
started := time.Now()
defer func() {
if r.metrics != nil {
r.metrics.ObserveReconcileRun(runErr, time.Since(started))
}
}()
now := r.now().UTC()
token, err := newClaimToken()
if err != nil {
return err
}
candidates, err := r.repository.ClaimDueReconcile(ctx, store.ReconcileClaim{
Owner: r.instanceID, Token: token, Now: now,
LeaseDuration: r.leaseDuration, Limit: r.batchSize,
})
if err != nil {
return fmt.Errorf("claim reconciliation candidates: %w", err)
}
var runErrors []error
for _, candidate := range candidates {
if err := ctx.Err(); err != nil {
return err
}
itemNow := r.now().UTC()
renewed, err := r.repository.RenewReconcileLease(
ctx, candidate.Device.ID, r.instanceID, token, itemNow, r.leaseDuration,
)
if err != nil {
r.observeItem("error")
runErrors = append(runErrors, fmt.Errorf("renew reconcile device lease: %w", err))
continue
}
if !renewed {
r.observeItem("lease_lost")
continue
}
itemCtx, cancel := context.WithTimeout(ctx, r.operationTimeout)
err = r.reconcileOne(itemCtx, ctx, candidate, itemNow, r.instanceID, token)
cancel()
if errors.Is(err, store.ErrReconcileLeaseLost) {
r.observeItem("lease_lost")
continue
}
if err != nil {
r.observeItem("error")
runErrors = append(runErrors, fmt.Errorf("reconcile device %s: %w", candidate.Device.ID, err))
} else {
r.observeItem("success")
}
}
if r.metrics != nil {
snapshot, err := r.repository.ConvergenceSnapshot(ctx)
if err != nil {
runErrors = append(runErrors, fmt.Errorf("read convergence metrics: %w", err))
} else {
r.metrics.SetConvergence(snapshot.Total, snapshot.Unconverged)
}
}
return errors.Join(runErrors...)
}
func (r *Reconciler) reconcileOne(
ctx context.Context,
persistCtx context.Context,
candidate store.ReconcileCandidate,
now time.Time,
owner, token string,
) error {
if candidate.Device.DesiredState == device.DesiredDisabled {
if err := r.media.DeletePath(ctx, candidate.Device.PathName); err == nil {
return r.repository.CompleteReconcile(
persistCtx, candidate.Device.ID, candidate.Device.Generation, owner, token, now,
)
} else if persistCtx.Err() != nil {
return persistCtx.Err()
} else {
return r.persistFailure(persistCtx, candidate, now, owner, token, err)
}
}
result, err := r.discovery.Probe(ctx, onvif.Target{
EndpointRef: candidate.Device.EndpointRef, CredentialRef: candidate.Device.CredentialRef,
})
if err == nil {
err = validateStreamURI(result.StreamURI)
}
if err == nil {
_, err = r.media.EnsurePath(ctx, candidate.Device.PathName, result.StreamURI)
}
if err == nil {
return r.repository.CompleteReconcile(
persistCtx, candidate.Device.ID, candidate.Device.Generation, owner, token, now,
)
}
if persistCtx.Err() != nil {
return persistCtx.Err()
}
return r.persistFailure(persistCtx, candidate, now, owner, token, err)
}
func (r *Reconciler) persistFailure(
ctx context.Context,
candidate store.ReconcileCandidate,
now time.Time,
owner, token string,
err error,
) error {
failureCount := candidate.FailureCount + 1
nextAttempt := now.Add(r.backoff(failureCount))
errorCode := string(onvif.CodeOf(err))
var onvifError *onvif.Error
if !errors.As(err, &onvifError) {
errorCode = "media_error"
}
if markErr := r.repository.FailReconcile(
ctx, candidate.Device.ID, failureCount, nextAttempt, errorCode, owner, token, now,
); markErr != nil {
return errors.Join(err, fmt.Errorf("persist reconcile failure: %w", markErr))
}
return err
}
func (r *Reconciler) observeItem(result string) {
if r.metrics != nil {
r.metrics.ObserveReconcileItem(result)
}
}
func newClaimToken() (string, error) {
value := make([]byte, 18)
if _, err := rand.Read(value); err != nil {
return "", errors.New("generate reconcile claim token")
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func validateStreamURI(value string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" || (parsed.Scheme != "rtsp" && parsed.Scheme != "rtsps") {
return &onvif.Error{Code: onvif.ErrorInvalidReply, Err: fmt.Errorf("stream URI has invalid scheme or host")}
}
return nil
}
func (r *Reconciler) backoff(failureCount int) time.Duration {
if failureCount <= 1 {
return r.baseBackoff
}
value := r.baseBackoff
for step := 1; step < failureCount; step++ {
if value >= r.maxBackoff/2 {
return r.maxBackoff
}
value *= 2
}
if value > r.maxBackoff {
return r.maxBackoff
}
return value
}
func (r *Reconciler) Run(ctx context.Context, interval time.Duration, report func(error)) {
if err := r.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
report(err)
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := r.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
report(err)
}
}
}
}
+269
View File
@@ -0,0 +1,269 @@
package reconcile
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"yovision/sense/internal/device"
"yovision/sense/internal/onvif"
"yovision/sense/internal/store"
)
type recordingMedia struct {
calls int
changed int
deleted int
paths map[string]string
}
func (m *recordingMedia) EnsurePath(_ context.Context, name, source string) (bool, error) {
m.calls++
if m.paths == nil {
m.paths = make(map[string]string)
}
if m.paths[name] == source {
return false, nil
}
m.paths[name] = source
m.changed++
return true, nil
}
func (m *recordingMedia) DeletePath(_ context.Context, name string) error {
m.calls++
if m.paths != nil {
delete(m.paths, name)
}
m.deleted++
return nil
}
func TestReconcileConvergesOnceAndPersistsGeneration(t *testing.T) {
t.Parallel()
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
createReconcileDevice(t, repository)
discovery := onvif.NewFake(map[string]onvif.FakeScenario{
"onvif://camera-1": {Result: onvif.ProbeResult{
Profiles: []onvif.Profile{{Token: "main", Name: "Main", VideoEncoder: true}},
StreamURI: "rtsp://media.invalid/camera-1",
}},
})
media := &recordingMedia{}
reconciler := New(repository, discovery, media)
reconciler.now = func() time.Time { return time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC) }
if err := reconciler.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if err := reconciler.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if media.calls != 1 || media.changed != 1 {
t.Fatalf("converged generation should not repeat: calls=%d changed=%d", media.calls, media.changed)
}
}
func TestBackoffSurvivesStoreRestart(t *testing.T) {
t.Parallel()
databasePath := filepath.Join(t.TempDir(), "sense.db")
repository := openRepository(t, databasePath)
createReconcileDevice(t, repository)
failingDiscovery := onvif.NewFake(map[string]onvif.FakeScenario{
"onvif://camera-1": {ProbeError: onvif.ErrorAuthentication},
})
media := &recordingMedia{}
initialTime := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
first := New(repository, failingDiscovery, media)
first.now = func() time.Time { return initialTime }
err := first.RunOnce(context.Background())
if err == nil || onvif.CodeOf(err) != onvif.ErrorAuthentication {
t.Fatalf("expected authentication failure, got %v", err)
}
if err := repository.Close(); err != nil {
t.Fatal(err)
}
reopened, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(databasePath))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = reopened.Close() })
successDiscovery := onvif.NewFake(map[string]onvif.FakeScenario{
"onvif://camera-1": {Result: onvif.ProbeResult{
Profiles: []onvif.Profile{{Token: "main", VideoEncoder: true}},
StreamURI: "rtsp://media.invalid/camera-1",
}},
})
afterRestart := New(reopened, successDiscovery, media)
afterRestart.now = func() time.Time { return initialTime.Add(500 * time.Millisecond) }
if err := afterRestart.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if media.calls != 0 {
t.Fatal("backoff window must survive restart")
}
afterRestart.now = func() time.Time { return initialTime.Add(time.Second) }
if err := afterRestart.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if media.calls != 1 {
t.Fatal("device must retry when persisted backoff expires")
}
}
func TestCancellationDoesNotPersistFailure(t *testing.T) {
t.Parallel()
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
createReconcileDevice(t, repository)
discovery := onvif.NewFake(map[string]onvif.FakeScenario{
"onvif://camera-1": {
DelayMillis: 100,
Result: onvif.ProbeResult{
Profiles: []onvif.Profile{{Token: "main", VideoEncoder: true}},
StreamURI: "rtsp://media.invalid/camera-1",
},
},
})
reconciler := New(repository, discovery, &recordingMedia{})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
defer cancel()
err := reconciler.RunOnce(ctx)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected cancellation, got %v", err)
}
candidates, err := repository.ListDueReconcile(context.Background(), time.Now().Add(time.Hour), 10)
if err != nil {
t.Fatal(err)
}
if len(candidates) != 1 || candidates[0].FailureCount != 0 {
t.Fatalf("cancellation must not consume retry budget: %+v", candidates)
}
}
func TestDisabledDeviceDeletesOnlyItsExactPath(t *testing.T) {
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
createReconcileDevice(t, repository)
media := &recordingMedia{paths: map[string]string{"camera-1": "source", "other": "keep"}}
if err := repository.SetDesiredState(context.Background(), "camera-1", device.DesiredDisabled); err != nil {
t.Fatal(err)
}
reconciler := New(repository, onvif.NewFake(nil), media)
if err := reconciler.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if media.deleted != 1 || media.paths["other"] != "keep" {
t.Fatalf("disabled convergence touched the wrong paths: %+v", media.paths)
}
value, err := repository.GetDevice(context.Background(), "camera-1")
if err != nil || value.ActualState != device.ActualOffline {
t.Fatalf("disabled device did not converge offline: %+v %v", value, err)
}
}
type lostLeaseRepository struct {
completed bool
failed bool
}
func (r *lostLeaseRepository) ClaimDueReconcile(
context.Context,
store.ReconcileClaim,
) ([]store.ReconcileCandidate, error) {
return []store.ReconcileCandidate{{Device: device.Device{
ID: "camera-lost", DesiredState: device.DesiredEnabled,
EndpointRef: "onvif://camera-lost", PathName: "camera-lost", Generation: 1,
}}}, nil
}
func (r *lostLeaseRepository) RenewReconcileLease(
context.Context, string, string, string, time.Time, time.Duration,
) (bool, error) {
return false, nil
}
func (r *lostLeaseRepository) CompleteReconcile(
context.Context, string, int64, string, string, time.Time,
) error {
r.completed = true
return nil
}
func (r *lostLeaseRepository) FailReconcile(
context.Context, string, int, time.Time, string, string, string, time.Time,
) error {
r.failed = true
return nil
}
func (r *lostLeaseRepository) ConvergenceSnapshot(context.Context) (store.ConvergenceSnapshot, error) {
return store.ConvergenceSnapshot{}, nil
}
func TestLostLeaseSkipsAllExternalAndStoreMutations(t *testing.T) {
t.Parallel()
repository := &lostLeaseRepository{}
media := &recordingMedia{}
reconciler := New(repository, onvif.NewFake(nil), media)
if err := reconciler.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if media.calls != 0 || repository.completed || repository.failed {
t.Fatalf("lost lease performed a side effect: media=%d completed=%v failed=%v",
media.calls, repository.completed, repository.failed)
}
}
func TestPerItemTimeoutPersistsRetryWhenParentIsAlive(t *testing.T) {
t.Parallel()
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
createReconcileDevice(t, repository)
discovery := onvif.NewFake(map[string]onvif.FakeScenario{
"onvif://camera-1": {
DelayMillis: 100,
Result: onvif.ProbeResult{StreamURI: "rtsp://media.invalid/camera-1"},
},
})
reconciler := NewWithOptions(repository, discovery, &recordingMedia{}, Options{
InstanceID: "ins_test", LeaseDuration: time.Second, OperationTimeout: 5 * time.Millisecond,
})
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
reconciler.now = func() time.Time { return now }
if err := reconciler.RunOnce(context.Background()); err == nil {
t.Fatal("expected bounded operation timeout")
}
candidates, err := repository.ListDueReconcile(context.Background(), now.Add(time.Hour), 1)
if err != nil {
t.Fatal(err)
}
if len(candidates) != 1 || candidates[0].FailureCount != 1 {
t.Fatalf("operation timeout did not persist retry state: %+v", candidates)
}
}
func openRepository(t *testing.T, path string) *store.SQLite {
t.Helper()
repository, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(path))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = repository.Close() })
return repository
}
func createReconcileDevice(t *testing.T, repository *store.SQLite) {
t.Helper()
ctx := context.Background()
if err := repository.EnsureSite(ctx, device.Site{TenantID: "tenant", ID: "site", Name: "Site"}); err != nil {
t.Fatal(err)
}
if err := repository.CreateDevice(ctx, device.Device{
ID: "camera-1", TenantID: "tenant", SiteID: "site", SerialNumber: "camera-1", Name: "Camera 1",
Modality: device.ModalityVideo, Capabilities: []device.Capability{device.CapabilityVideoCapture},
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
EndpointRef: "onvif://camera-1", CredentialRef: "secret://camera-1", PathName: "camera-1",
}); err != nil {
t.Fatal(err)
}
}
+127
View File
@@ -0,0 +1,127 @@
package store
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
)
type AuditActorType string
const (
AuditActorUser AuditActorType = "user"
AuditActorService AuditActorType = "service"
AuditActorSystem AuditActorType = "system"
)
// AuditContext is supplied by a verified caller boundary. Repository callers
// without an HTTP principal use the explicit system/sense fallback.
type AuditContext struct {
ActorType AuditActorType
ActorID string
Reason string
TraceID string
}
type auditContextKey struct{}
func WithAuditContext(ctx context.Context, value AuditContext) context.Context {
return context.WithValue(ctx, auditContextKey{}, value)
}
func auditFromContext(ctx context.Context) AuditContext {
value, ok := ctx.Value(auditContextKey{}).(AuditContext)
if !ok {
return AuditContext{ActorType: AuditActorSystem, ActorID: "sense"}
}
return value
}
func validateAudit(value AuditContext) error {
if value.ActorType != AuditActorUser && value.ActorType != AuditActorService && value.ActorType != AuditActorSystem {
return errors.New("invalid audit actor type")
}
if strings.TrimSpace(value.ActorID) == "" || len(value.ActorID) > 200 {
return errors.New("invalid audit actor ID")
}
if len(value.Reason) > 500 {
return errors.New("audit reason is too long")
}
if len(value.TraceID) > 128 {
return errors.New("audit trace ID is too long")
}
return nil
}
type postgresAuditEvent struct {
EventType string
TenantID string
SiteID string
DeviceID string
Generation int64
QuotaSourceVersion int64
AreaPolicySourceVersion int64
Payload any
OccurredAt time.Time
}
func insertPostgresAudit(
ctx context.Context,
tx *sql.Tx,
event postgresAuditEvent,
) error {
audit := auditFromContext(ctx)
if err := validateAudit(audit); err != nil {
return err
}
eventID, err := newAuditEventID()
if err != nil {
return errors.New("generate audit event ID")
}
payload, err := json.Marshal(event.Payload)
if err != nil {
return errors.New("encode audit payload")
}
if !json.Valid(payload) {
return errors.New("invalid audit payload")
}
occurredAt := event.OccurredAt.UTC()
if occurredAt.IsZero() {
occurredAt = time.Now().UTC()
}
_, err = tx.ExecContext(ctx, `INSERT INTO sense.device_operation_outbox(
event_id, event_type, tenant_id, site_id, device_id,
actor_type, actor_id, reason, trace_id, aggregate_generation,
quota_source_version, area_policy_source_version, payload,
occurred_at, available_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''), NULLIF($9, ''),
$10, $11, $12, $13, $14, $14)`,
eventID, event.EventType, event.TenantID, event.SiteID, event.DeviceID,
audit.ActorType, audit.ActorID, audit.Reason, audit.TraceID,
event.Generation, nullableVersion(event.QuotaSourceVersion),
nullableVersion(event.AreaPolicySourceVersion), payload, occurredAt)
if err != nil {
return errors.New("insert device operation audit")
}
return nil
}
func newAuditEventID() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", err
}
return "audit_" + hex.EncodeToString(value), nil
}
func nullableVersion(value int64) any {
if value < 1 {
return nil
}
return value
}
+164
View File
@@ -0,0 +1,164 @@
package store
import (
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"strconv"
"time"
"yovision/sense/internal/device"
)
var (
ErrETagMismatch = errors.New("device ETag mismatch")
ErrIdempotencyConflict = errors.New("idempotency key body conflict")
ErrDuplicateSerialNumber = errors.New("duplicate device serial number")
)
type ControlProjectionVersions struct {
QuotaSourceVersion *int64 `json:"quota_source_version"`
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
SyncedAt *time.Time `json:"synced_at"`
}
// ControlDevice is deliberately safe to serialize. It contains configured
// booleans, never endpoint, credential or profile-token values.
type ControlDevice struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
SiteID string `json:"site_id"`
SerialNumber string `json:"serial_number"`
Name string `json:"name"`
Modality device.Modality `json:"modality"`
Capabilities []device.Capability `json:"capabilities"`
AreaID string `json:"area_id"`
DesiredState device.DesiredState `json:"desired_state"`
ActualState device.ActualState `json:"actual_state"`
AdapterStatus string `json:"adapter_status"`
EndpointConfigured bool `json:"endpoint_configured"`
CredentialConfigured bool `json:"credential_configured"`
Generation int64 `json:"generation"`
ObservedGeneration int64 `json:"observed_generation"`
Converged bool `json:"converged"`
FailureCount int `json:"failure_count"`
NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`
LastErrorCode *string `json:"last_error_code,omitempty"`
ProjectionVersions ControlProjectionVersions `json:"projection_versions"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ResourceVersion int64 `json:"-"`
}
type ControlSiteQuota struct {
Status string
UsedVideoChannels int
MaxVideoChannels *int
AvailableVideoChannels *int
OverLimit bool
SourceVersion *int64
SyncedAt *time.Time
}
type ControlListFilter struct {
Limit int
AfterCreated *time.Time
AfterDeviceID string
Modality *device.Modality
Capability *device.Capability
DesiredState *device.DesiredState
ActualState *device.ActualState
}
type ControlDevicePage struct {
Items []ControlDevice
HasMore bool
Quota ControlSiteQuota
}
type IdempotencyScope struct {
PrincipalID string
TenantID string
SiteID string
Operation string
Key string
RequestHash [sha256.Size]byte
TraceID string
}
type ControlCreateRequest struct {
Scope IdempotencyScope
Device device.Device
}
type ControlCreateResult struct {
Device ControlDevice
AcceptedAt time.Time
TraceID string
ETag string
Location string
Replay bool
}
type ControlPatch struct {
Name *string
AreaID *string
EndpointRef *string
CredentialRef *string
ProfileToken *string
}
type ControlMutationResult struct {
Device ControlDevice
AcceptedAt time.Time
TraceID string
ETag string
}
type ControlBatchItem struct {
DeviceID string
ETag string
DesiredState device.DesiredState
}
type ControlBatchRequest struct {
Scope IdempotencyScope
Reason string
Items []ControlBatchItem
}
type ControlBatchItemResult struct {
DeviceID string `json:"device_id"`
Status string `json:"status"`
ErrorCode *string `json:"error_code"`
Message *string `json:"message"`
Generation *int64 `json:"generation"`
}
type ControlBatchOperation struct {
ID string `json:"id"`
TenantID string `json:"-"`
SiteID string `json:"-"`
Status string `json:"status"`
SubmittedAt time.Time `json:"submitted_at"`
CompletedAt *time.Time `json:"completed_at"`
Results []ControlBatchItemResult `json:"results"`
TraceID string `json:"trace_id"`
Replay bool `json:"-"`
}
type ControlRepository interface {
ListControlDevices(context.Context, string, string, ControlListFilter) (ControlDevicePage, error)
CreateControlDevice(context.Context, ControlCreateRequest) (ControlCreateResult, error)
GetControlDevice(context.Context, string, string, string) (ControlDevice, error)
PatchControlDevice(context.Context, string, string, string, string, ControlPatch) (ControlMutationResult, error)
SetControlDesiredState(context.Context, string, string, string, string, device.DesiredState) (ControlMutationResult, error)
BatchSetControlDesiredState(context.Context, ControlBatchRequest) (ControlBatchOperation, error)
GetControlOperation(context.Context, string, string) (ControlBatchOperation, error)
}
func DeviceETag(deviceID string, resourceVersion int64) string {
digest := sha256.Sum256([]byte(deviceID + "\x00" + strconv.FormatInt(resourceVersion, 10)))
return `"` + base64.RawURLEncoding.EncodeToString(digest[:18]) + `"`
}
+891
View File
@@ -0,0 +1,891 @@
package store
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/url"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5/pgconn"
"yovision/sense/internal/device"
)
const controlReceiptTTL = 24 * time.Hour
const controlDeviceColumns = `d.id, d.tenant_id, d.site_id, d.serial_number, d.name, d.modality,
d.area_id, d.desired_state, d.actual_state,
(d.endpoint_ref <> ''), (d.credential_ref <> ''),
d.generation, d.resource_version,
r.observed_generation, r.failure_count, r.next_attempt_at, r.last_error_code,
d.quota_source_version, d.area_policy_source_version,
GREATEST(
(SELECT q.synced_at FROM sense.site_quota_projection_state q
WHERE q.tenant_id = d.tenant_id AND q.site_id = d.site_id
AND q.source_version = d.quota_source_version),
(SELECT a.synced_at FROM sense.area_policy_projection_state a
WHERE a.tenant_id = d.tenant_id AND a.site_id = d.site_id
AND a.area_id = d.area_id AND a.source_version = d.area_policy_source_version)
),
d.created_at, d.updated_at,
COALESCE((SELECT jsonb_agg(c.capability ORDER BY c.capability)
FROM sense.device_capabilities c WHERE c.device_id = d.id), '[]'::jsonb)::text`
const controlDeviceSelect = `SELECT ` + controlDeviceColumns + `
FROM sense.devices d JOIN sense.reconcile_state r ON r.device_id = d.id`
type controlScanner interface {
Scan(...any) error
}
func scanControlDevice(row controlScanner) (ControlDevice, error) {
var value ControlDevice
var nextAttempt, syncedAt sql.NullTime
var lastError sql.NullString
var quotaVersion, areaVersion sql.NullInt64
var capabilitiesJSON string
err := row.Scan(
&value.ID, &value.TenantID, &value.SiteID, &value.SerialNumber, &value.Name,
&value.Modality, &value.AreaID, &value.DesiredState, &value.ActualState,
&value.EndpointConfigured, &value.CredentialConfigured,
&value.Generation, &value.ResourceVersion, &value.ObservedGeneration,
&value.FailureCount, &nextAttempt, &lastError, &quotaVersion, &areaVersion,
&syncedAt, &value.CreatedAt, &value.UpdatedAt, &capabilitiesJSON,
)
if err != nil {
return ControlDevice{}, err
}
if err := json.Unmarshal([]byte(capabilitiesJSON), &value.Capabilities); err != nil {
return ControlDevice{}, errors.New("decode postgres control device capabilities")
}
if value.Capabilities == nil {
value.Capabilities = make([]device.Capability, 0)
}
if nextAttempt.Valid {
point := nextAttempt.Time.UTC()
value.NextAttemptAt = &point
}
if lastError.Valid {
code := lastError.String
value.LastErrorCode = &code
}
if quotaVersion.Valid {
version := quotaVersion.Int64
value.ProjectionVersions.QuotaSourceVersion = &version
}
if areaVersion.Valid {
version := areaVersion.Int64
value.ProjectionVersions.AreaPolicySourceVersion = &version
}
if syncedAt.Valid {
point := syncedAt.Time.UTC()
value.ProjectionVersions.SyncedAt = &point
}
value.Converged = value.ObservedGeneration >= value.Generation && value.FailureCount == 0
value.AdapterStatus = controlAdapterStatus(value)
return value, nil
}
func controlAdapterStatus(value ControlDevice) string {
if value.LastErrorCode != nil {
switch *value.LastErrorCode {
case "authentication_failed":
return "authentication_failed"
case "adapter_not_ready":
return "adapter_not_ready"
default:
return "unavailable"
}
}
if value.Converged {
return "ready"
}
if value.ActualState == device.ActualFailed || value.ActualState == device.ActualOffline {
return "unavailable"
}
return "pending"
}
func (s *Postgres) GetControlDevice(
ctx context.Context, tenantID, siteID, deviceID string,
) (ControlDevice, error) {
value, err := scanControlDevice(s.db.QueryRowContext(ctx, controlDeviceSelect+`
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3`, tenantID, siteID, deviceID))
if errors.Is(err, sql.ErrNoRows) {
return ControlDevice{}, ErrNotFound
}
if err != nil {
return ControlDevice{}, errors.New("get postgres control device")
}
return value, nil
}
func (s *Postgres) ListControlDevices(
ctx context.Context, tenantID, siteID string, filter ControlListFilter,
) (ControlDevicePage, error) {
quota, err := s.controlSiteQuota(ctx, tenantID, siteID)
if err != nil {
return ControlDevicePage{}, err
}
query := controlDeviceSelect + ` WHERE d.tenant_id = $1 AND d.site_id = $2`
arguments := []any{tenantID, siteID}
appendCondition := func(clause string, value any) {
arguments = append(arguments, value)
query += fmt.Sprintf(clause, len(arguments))
}
if filter.Modality != nil {
appendCondition(` AND d.modality = $%d`, *filter.Modality)
}
if filter.Capability != nil {
appendCondition(` AND EXISTS (SELECT 1 FROM sense.device_capabilities fc
WHERE fc.device_id = d.id AND fc.capability = $%d)`, *filter.Capability)
}
if filter.DesiredState != nil {
appendCondition(` AND d.desired_state = $%d`, *filter.DesiredState)
}
if filter.ActualState != nil {
appendCondition(` AND d.actual_state = $%d`, *filter.ActualState)
}
if filter.AfterCreated != nil {
arguments = append(arguments, filter.AfterCreated.UTC(), filter.AfterDeviceID)
query += fmt.Sprintf(` AND (d.created_at, d.id) > ($%d, $%d)`, len(arguments)-1, len(arguments))
}
arguments = append(arguments, filter.Limit+1)
query += fmt.Sprintf(` ORDER BY d.created_at ASC, d.id ASC LIMIT $%d`, len(arguments))
rows, err := s.db.QueryContext(ctx, query, arguments...)
if err != nil {
return ControlDevicePage{}, errors.New("list postgres control devices")
}
defer rows.Close()
values := make([]ControlDevice, 0, filter.Limit+1)
for rows.Next() {
value, scanErr := scanControlDevice(rows)
if scanErr != nil {
return ControlDevicePage{}, errors.New("scan postgres control device page")
}
values = append(values, value)
}
if err := rows.Err(); err != nil {
return ControlDevicePage{}, errors.New("iterate postgres control device page")
}
hasMore := len(values) > filter.Limit
if hasMore {
values = values[:filter.Limit]
}
return ControlDevicePage{Items: values, HasMore: hasMore, Quota: quota}, nil
}
func (s *Postgres) controlSiteQuota(ctx context.Context, tenantID, siteID string) (ControlSiteQuota, error) {
var maximum int
var sourceVersion int64
var syncedAt time.Time
err := s.db.QueryRowContext(ctx, `SELECT max_video_channels, source_version, source_updated_at
FROM bell.site_quota_v1 WHERE tenant_id = $1 AND site_id = $2`, tenantID, siteID).
Scan(&maximum, &sourceVersion, &syncedAt)
if errors.Is(err, sql.ErrNoRows) {
return ControlSiteQuota{}, ErrNotFound
}
if err != nil {
return ControlSiteQuota{}, errors.New("read postgres control site quota")
}
var used int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sense.devices d
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.desired_state = 'enabled'
AND EXISTS (SELECT 1 FROM sense.device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture')`, tenantID, siteID).Scan(&used); err != nil {
return ControlSiteQuota{}, errors.New("count postgres control site video channels")
}
status := "current"
if maximum < 1 || maximum > device.MaximumVideoChannels || sourceVersion < 1 || syncedAt.IsZero() {
status = "invalid"
return ControlSiteQuota{Status: status, UsedVideoChannels: used, OverLimit: false}, nil
}
available := maximum - used
if available < 0 {
available = 0
}
point := syncedAt.UTC()
return ControlSiteQuota{
Status: status, UsedVideoChannels: used, MaxVideoChannels: &maximum,
AvailableVideoChannels: &available, OverLimit: used > maximum,
SourceVersion: &sourceVersion, SyncedAt: &point,
}, nil
}
type controlReceipt struct {
Status int
Body []byte
ETag string
Location string
TraceID string
CreatedAt time.Time
}
func controlScopeHash(scope IdempotencyScope) [sha256.Size]byte {
encoded, _ := json.Marshal([]string{
scope.PrincipalID, scope.TenantID, scope.SiteID, scope.Operation, scope.Key,
})
return sha256.Sum256(encoded)
}
func readControlReceipt(
ctx context.Context, tx *sql.Tx, scope IdempotencyScope, now time.Time,
) (controlReceipt, bool, error) {
var receipt controlReceipt
scopeHash := controlScopeHash(scope)
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, hex.EncodeToString(scopeHash[:])); err != nil {
return receipt, false, errors.New("lock postgres Control API idempotency scope")
}
var storedRequestHash []byte
var etag, location sql.NullString
var expiresAt time.Time
err := tx.QueryRowContext(ctx, `SELECT request_hash, response_status, response_body::text,
response_etag, response_location, trace_id, created_at, expires_at
FROM sense.control_idempotency_receipts WHERE scope_hash = $1`, scopeHash[:]).
Scan(&storedRequestHash, &receipt.Status, &receipt.Body, &etag, &location,
&receipt.TraceID, &receipt.CreatedAt, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return receipt, false, nil
}
if err != nil {
return receipt, false, errors.New("read postgres Control API idempotency receipt")
}
if !expiresAt.After(now) {
if _, err := tx.ExecContext(ctx, `DELETE FROM sense.control_idempotency_receipts
WHERE scope_hash = $1`, scopeHash[:]); err != nil {
return receipt, false, errors.New("expire postgres Control API idempotency receipt")
}
return controlReceipt{}, false, nil
}
if subtle.ConstantTimeCompare(storedRequestHash, scope.RequestHash[:]) != 1 {
return receipt, false, ErrIdempotencyConflict
}
receipt.ETag = etag.String
receipt.Location = location.String
return receipt, true, nil
}
func writeControlReceipt(
ctx context.Context, tx *sql.Tx, scope IdempotencyScope, receipt controlReceipt,
) error {
scopeHash := controlScopeHash(scope)
_, err := tx.ExecContext(ctx, `INSERT INTO sense.control_idempotency_receipts(
scope_hash, request_hash, operation_name, principal_id, tenant_id, site_id,
response_status, response_body, response_etag, response_location, trace_id,
created_at, expires_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, NULLIF($9, ''), NULLIF($10, ''), $11, $12, $13)`,
scopeHash[:], scope.RequestHash[:], scope.Operation, scope.PrincipalID,
scope.TenantID, scope.SiteID, receipt.Status, string(receipt.Body),
receipt.ETag, receipt.Location, receipt.TraceID, receipt.CreatedAt,
receipt.CreatedAt.Add(controlReceiptTTL))
if err != nil {
return errors.New("write postgres Control API idempotency receipt")
}
// Bound opportunistic cleanup; never scans or deletes unexpired receipts.
_, _ = tx.ExecContext(ctx, `DELETE FROM sense.control_idempotency_receipts
WHERE scope_hash IN (SELECT scope_hash FROM sense.control_idempotency_receipts
WHERE expires_at <= $1 ORDER BY expires_at LIMIT 32)`, receipt.CreatedAt)
return nil
}
func (s *Postgres) CreateControlDevice(
ctx context.Context, request ControlCreateRequest,
) (ControlCreateResult, error) {
now := time.Now().UTC()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return ControlCreateResult{}, errors.New("begin postgres Control API device create")
}
defer tx.Rollback()
receipt, found, err := readControlReceipt(ctx, tx, request.Scope, now)
if err != nil {
return ControlCreateResult{}, err
}
if found {
var value ControlDevice
if err := json.Unmarshal(receipt.Body, &value); err != nil {
return ControlCreateResult{}, errors.New("decode postgres device creation receipt")
}
if err := tx.Commit(); err != nil {
return ControlCreateResult{}, errors.New("commit postgres device creation replay")
}
return ControlCreateResult{
Device: value, AcceptedAt: receipt.CreatedAt, TraceID: receipt.TraceID,
ETag: receipt.ETag, Location: receipt.Location, Replay: true,
}, nil
}
value := request.Device
if value.Generation == 0 {
value.Generation = 1
}
if value.ResourceVersion == 0 {
value.ResourceVersion = 1
}
if value.ActualState == "" {
value.ActualState = device.ActualPending
}
value.CreatedAt = now
value.UpdatedAt = now
if err := createControlDeviceTx(ctx, tx, value, now); err != nil {
return ControlCreateResult{}, err
}
created, err := scanControlDevice(tx.QueryRowContext(ctx, controlDeviceSelect+`
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3`, value.TenantID, value.SiteID, value.ID))
if err != nil {
return ControlCreateResult{}, errors.New("read created postgres control device")
}
responseBody, err := json.Marshal(created)
if err != nil {
return ControlCreateResult{}, errors.New("encode created postgres control device")
}
etag := DeviceETag(created.ID, created.ResourceVersion)
location := "/api/v1/sites/" + url.PathEscape(created.SiteID) + "/devices/" + url.PathEscape(created.ID)
receipt = controlReceipt{
Status: 201, Body: responseBody, ETag: etag, Location: location,
TraceID: request.Scope.TraceID, CreatedAt: now,
}
if err := writeControlReceipt(ctx, tx, request.Scope, receipt); err != nil {
return ControlCreateResult{}, err
}
if err := tx.Commit(); err != nil {
return ControlCreateResult{}, errors.New("commit postgres Control API device create")
}
return ControlCreateResult{
Device: created, AcceptedAt: now, TraceID: receipt.TraceID,
ETag: etag, Location: location,
}, nil
}
func createControlDeviceTx(ctx context.Context, tx *sql.Tx, value device.Device, now time.Time) error {
if err := value.Validate(); err != nil {
return fmt.Errorf("validate Control API device: %w", err)
}
areaVersion, err := checkPostgresAreaPolicy(
ctx, tx, value.TenantID, value.SiteID, value.AreaID,
value.HasCapability(device.CapabilityVideoCapture), now,
)
if err != nil {
return err
}
var quotaVersion int64
if value.ConsumesVideoChannel() {
quotaVersion, err = checkPostgresVideoQuota(ctx, tx, value.TenantID, value.SiteID, now)
if err != nil {
return err
}
}
_, err = tx.ExecContext(ctx, `INSERT INTO sense.devices(
id, tenant_id, site_id, area_id, serial_number, name, modality,
desired_state, actual_state, endpoint_ref, credential_ref, profile_token,
path_name, generation, resource_version, quota_source_version,
area_policy_source_version, created_at, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)`,
value.ID, value.TenantID, value.SiteID, value.AreaID, value.SerialNumber, value.Name,
value.Modality, value.DesiredState, value.ActualState, value.EndpointRef,
value.CredentialRef, value.ProfileToken, value.PathName, value.Generation,
value.ResourceVersion, nullableVersion(quotaVersion), areaVersion,
value.CreatedAt, value.UpdatedAt)
if err != nil {
var postgresError *pgconn.PgError
if errors.As(err, &postgresError) && postgresError.Code == "23505" &&
strings.Contains(postgresError.ConstraintName, "serial_number") {
return ErrDuplicateSerialNumber
}
return errors.New("insert postgres Control API device")
}
for _, capability := range sortedCapabilities(value.Capabilities) {
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.device_capabilities(device_id, capability)
VALUES ($1, $2)`, value.ID, capability); err != nil {
return errors.New("insert postgres Control API device capability")
}
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.reconcile_state(device_id, updated_at)
VALUES ($1, $2)`, value.ID, now); err != nil {
return errors.New("insert postgres Control API reconcile state")
}
return insertPostgresAudit(ctx, tx, postgresAuditEvent{
EventType: "device.created", TenantID: value.TenantID, SiteID: value.SiteID,
DeviceID: value.ID, Generation: value.Generation,
QuotaSourceVersion: quotaVersion, AreaPolicySourceVersion: areaVersion,
OccurredAt: now,
Payload: map[string]any{
"kind": "device_created", "area_id": value.AreaID, "modality": value.Modality,
"capabilities": sortedCapabilities(value.Capabilities), "desired_state": value.DesiredState,
},
})
}
var _ ControlRepository = (*Postgres)(nil)
func (s *Postgres) PatchControlDevice(
ctx context.Context, tenantID, siteID, deviceID, expectedETag string, patch ControlPatch,
) (ControlMutationResult, error) {
now := time.Now().UTC()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return ControlMutationResult{}, errors.New("begin postgres Control API device patch")
}
defer tx.Rollback()
var name, areaID, endpointRef, credentialRef, profileToken string
var generation, resourceVersion int64
var desired device.DesiredState
var quotaVersion, areaVersion sql.NullInt64
var hasVideo bool
err = tx.QueryRowContext(ctx, `SELECT d.name, d.area_id, d.endpoint_ref,
d.credential_ref, d.profile_token, d.generation, d.resource_version,
d.desired_state, d.quota_source_version, d.area_policy_source_version,
EXISTS (SELECT 1 FROM sense.device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture')
FROM sense.devices d
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3 FOR UPDATE`,
tenantID, siteID, deviceID).Scan(
&name, &areaID, &endpointRef, &credentialRef, &profileToken,
&generation, &resourceVersion, &desired, &quotaVersion, &areaVersion, &hasVideo,
)
if errors.Is(err, sql.ErrNoRows) {
return ControlMutationResult{}, ErrNotFound
}
if err != nil {
return ControlMutationResult{}, errors.New("read postgres Control API device patch state")
}
if DeviceETag(deviceID, resourceVersion) != expectedETag {
return ControlMutationResult{}, ErrETagMismatch
}
changedFields := make([]string, 0, 5)
reconcileChanged := false
if patch.Name != nil && *patch.Name != name {
name = *patch.Name
changedFields = append(changedFields, "name")
}
if patch.AreaID != nil && *patch.AreaID != areaID {
version, policyErr := checkPostgresAreaPolicy(
ctx, tx, tenantID, siteID, *patch.AreaID, hasVideo, now,
)
if policyErr != nil {
return ControlMutationResult{}, policyErr
}
areaID = *patch.AreaID
areaVersion = sql.NullInt64{Int64: version, Valid: true}
changedFields = append(changedFields, "area_id")
}
if patch.EndpointRef != nil && *patch.EndpointRef != endpointRef {
endpointRef = *patch.EndpointRef
changedFields = append(changedFields, "endpoint_ref")
reconcileChanged = true
}
if patch.CredentialRef != nil && *patch.CredentialRef != credentialRef {
credentialRef = *patch.CredentialRef
changedFields = append(changedFields, "credential_ref")
reconcileChanged = true
}
if patch.ProfileToken != nil && *patch.ProfileToken != profileToken {
profileToken = *patch.ProfileToken
changedFields = append(changedFields, "profile_token")
reconcileChanged = true
}
if len(changedFields) > 0 {
resourceVersion++
if reconcileChanged {
generation++
}
_, err = tx.ExecContext(ctx, `UPDATE sense.devices SET
name = $1, area_id = $2, endpoint_ref = $3, credential_ref = $4,
profile_token = $5, generation = $6, resource_version = $7,
area_policy_source_version = $8,
actual_state = CASE WHEN $9 THEN 'pending' ELSE actual_state END,
updated_at = $10
WHERE tenant_id = $11 AND site_id = $12 AND id = $13`,
name, areaID, endpointRef, credentialRef, profileToken, generation,
resourceVersion, nullableVersion(areaVersion.Int64), reconcileChanged, now,
tenantID, siteID, deviceID)
if err != nil {
return ControlMutationResult{}, errors.New("update postgres Control API device configuration")
}
if reconcileChanged {
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
lease_owner = NULL, lease_token = NULL, lease_until = NULL,
updated_at = $1 WHERE device_id = $2`, now, deviceID); err != nil {
return ControlMutationResult{}, errors.New("reset postgres Control API reconcile state")
}
}
}
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
EventType: "device.configuration.accepted", TenantID: tenantID, SiteID: siteID,
DeviceID: deviceID, Generation: generation,
QuotaSourceVersion: quotaVersion.Int64, AreaPolicySourceVersion: areaVersion.Int64,
OccurredAt: now,
Payload: map[string]any{
"kind": "configuration_accepted", "changed": len(changedFields) > 0,
"changed_fields": changedFields, "area_id": areaID,
},
}); err != nil {
return ControlMutationResult{}, err
}
updated, err := scanControlDevice(tx.QueryRowContext(ctx, controlDeviceSelect+`
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3`, tenantID, siteID, deviceID))
if err != nil {
return ControlMutationResult{}, errors.New("read patched postgres control device")
}
if err := tx.Commit(); err != nil {
return ControlMutationResult{}, errors.New("commit postgres Control API device patch")
}
return ControlMutationResult{
Device: updated, AcceptedAt: now, TraceID: auditFromContext(ctx).TraceID,
ETag: DeviceETag(updated.ID, updated.ResourceVersion),
}, nil
}
func (s *Postgres) SetControlDesiredState(
ctx context.Context, tenantID, siteID, deviceID, expectedETag string, desired device.DesiredState,
) (ControlMutationResult, error) {
now := time.Now().UTC()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return ControlMutationResult{}, errors.New("begin postgres Control API desired-state update")
}
defer tx.Rollback()
updated, err := setControlDesiredStateTx(
ctx, tx, tenantID, siteID, deviceID, expectedETag, desired, now,
)
if err != nil {
return ControlMutationResult{}, err
}
if err := tx.Commit(); err != nil {
return ControlMutationResult{}, errors.New("commit postgres Control API desired-state update")
}
return ControlMutationResult{
Device: updated, AcceptedAt: now, TraceID: auditFromContext(ctx).TraceID,
ETag: DeviceETag(updated.ID, updated.ResourceVersion),
}, nil
}
func setControlDesiredStateTx(
ctx context.Context, tx *sql.Tx, tenantID, siteID, deviceID, expectedETag string,
desired device.DesiredState, now time.Time,
) (ControlDevice, error) {
var areaID, endpointRef, pathName string
var current device.DesiredState
var generation, resourceVersion int64
var quotaVersion, areaVersion sql.NullInt64
var hasVideo bool
err := tx.QueryRowContext(ctx, `SELECT d.area_id, d.desired_state, d.endpoint_ref,
d.path_name, d.generation, d.resource_version, d.quota_source_version,
d.area_policy_source_version,
EXISTS (SELECT 1 FROM sense.device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture')
FROM sense.devices d
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3 FOR UPDATE`,
tenantID, siteID, deviceID).Scan(
&areaID, &current, &endpointRef, &pathName, &generation, &resourceVersion,
&quotaVersion, &areaVersion, &hasVideo,
)
if errors.Is(err, sql.ErrNoRows) {
return ControlDevice{}, ErrNotFound
}
if err != nil {
return ControlDevice{}, errors.New("read postgres Control API desired state")
}
if DeviceETag(deviceID, resourceVersion) != expectedETag {
return ControlDevice{}, ErrETagMismatch
}
if current != desired {
var admittedQuota, admittedArea int64
if desired == device.DesiredEnabled && hasVideo {
if strings.TrimSpace(endpointRef) == "" || strings.TrimSpace(pathName) == "" {
return ControlDevice{}, errors.New("video adapter configuration is incomplete")
}
admittedArea, err = checkPostgresAreaPolicy(ctx, tx, tenantID, siteID, areaID, true, now)
if err != nil {
return ControlDevice{}, err
}
admittedQuota, err = checkPostgresVideoQuota(ctx, tx, tenantID, siteID, now)
if err != nil {
return ControlDevice{}, err
}
}
generation++
resourceVersion++
err = tx.QueryRowContext(ctx, `UPDATE sense.devices SET
desired_state = $1, actual_state = 'pending', generation = $2,
resource_version = $3,
quota_source_version = COALESCE($4, quota_source_version),
area_policy_source_version = COALESCE($5, area_policy_source_version),
updated_at = $6
WHERE tenant_id = $7 AND site_id = $8 AND id = $9
RETURNING quota_source_version, area_policy_source_version`,
desired, generation, resourceVersion, nullableVersion(admittedQuota),
nullableVersion(admittedArea), now, tenantID, siteID, deviceID).
Scan(&quotaVersion, &areaVersion)
if err != nil {
return ControlDevice{}, errors.New("update postgres Control API desired state")
}
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
lease_owner = NULL, lease_token = NULL, lease_until = NULL,
updated_at = $1 WHERE device_id = $2`, now, deviceID); err != nil {
return ControlDevice{}, errors.New("reset postgres Control API desired-state reconciliation")
}
}
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
EventType: "device.desired_state.accepted", TenantID: tenantID, SiteID: siteID,
DeviceID: deviceID, Generation: generation,
QuotaSourceVersion: quotaVersion.Int64, AreaPolicySourceVersion: areaVersion.Int64,
OccurredAt: now,
Payload: map[string]any{
"kind": "desired_state_accepted", "previous_desired_state": current,
"desired_state": desired, "changed": current != desired,
},
}); err != nil {
return ControlDevice{}, err
}
value, err := scanControlDevice(tx.QueryRowContext(ctx, controlDeviceSelect+`
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3`, tenantID, siteID, deviceID))
if err != nil {
return ControlDevice{}, errors.New("read updated postgres control desired state")
}
return value, nil
}
func (s *Postgres) BatchSetControlDesiredState(
ctx context.Context, request ControlBatchRequest,
) (ControlBatchOperation, error) {
now := time.Now().UTC()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return ControlBatchOperation{}, errors.New("begin postgres Control API batch")
}
defer tx.Rollback()
receipt, found, err := readControlReceipt(ctx, tx, request.Scope, now)
if err != nil {
return ControlBatchOperation{}, err
}
if found {
var operation ControlBatchOperation
if err := json.Unmarshal(receipt.Body, &operation); err != nil {
return ControlBatchOperation{}, errors.New("decode postgres Control API batch receipt")
}
operation.TenantID = request.Scope.TenantID
operation.SiteID = request.Scope.SiteID
operation.Replay = true
if err := tx.Commit(); err != nil {
return ControlBatchOperation{}, errors.New("commit postgres Control API batch replay")
}
return operation, nil
}
operationID, err := newControlOperationID(now)
if err != nil {
return ControlBatchOperation{}, err
}
counts := make(map[string]int, len(request.Items))
for _, item := range request.Items {
counts[item.DeviceID]++
}
if err := lockControlBatchDevices(
ctx, tx, request.Scope.TenantID, request.Scope.SiteID, counts,
); err != nil {
return ControlBatchOperation{}, err
}
results := make([]ControlBatchItemResult, 0, len(request.Items))
succeeded := 0
for index, item := range request.Items {
if counts[item.DeviceID] > 1 {
code, message := "invalid_request", "device_id is duplicated in this request"
results = append(results, ControlBatchItemResult{
DeviceID: item.DeviceID, Status: "rejected", ErrorCode: &code, Message: &message,
})
continue
}
savepoint := fmt.Sprintf("control_batch_%d", index)
if _, err := tx.ExecContext(ctx, "SAVEPOINT "+savepoint); err != nil {
return ControlBatchOperation{}, errors.New("create postgres Control API batch savepoint")
}
updated, itemErr := setControlDesiredStateTx(
ctx, tx, request.Scope.TenantID, request.Scope.SiteID,
item.DeviceID, item.ETag, item.DesiredState, now,
)
if itemErr != nil {
if _, rollbackErr := tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT "+savepoint); rollbackErr != nil {
return ControlBatchOperation{}, errors.New("rollback postgres Control API batch item")
}
code, status, message := controlBatchError(itemErr)
results = append(results, ControlBatchItemResult{
DeviceID: item.DeviceID, Status: status, ErrorCode: &code, Message: &message,
})
} else {
generation := updated.Generation
results = append(results, ControlBatchItemResult{
DeviceID: item.DeviceID, Status: "succeeded", Generation: &generation,
})
succeeded++
}
if _, err := tx.ExecContext(ctx, "RELEASE SAVEPOINT "+savepoint); err != nil {
return ControlBatchOperation{}, errors.New("release postgres Control API batch savepoint")
}
}
status := "partially_succeeded"
if succeeded == len(results) {
status = "succeeded"
} else if succeeded == 0 {
status = "failed"
}
completedAt := now
operation := ControlBatchOperation{
ID: operationID, TenantID: request.Scope.TenantID, SiteID: request.Scope.SiteID,
Status: status, SubmittedAt: now, CompletedAt: &completedAt,
Results: results, TraceID: request.Scope.TraceID,
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.batch_operations(
id, tenant_id, site_id, principal_id, status, trace_id, submitted_at, completed_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, operation.ID, operation.TenantID,
operation.SiteID, request.Scope.PrincipalID, operation.Status, operation.TraceID,
operation.SubmittedAt, operation.CompletedAt); err != nil {
return ControlBatchOperation{}, errors.New("insert postgres Control API batch operation")
}
for index, result := range results {
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.batch_operation_items(
operation_id, ordinal, device_id, status, error_code, message, generation
) VALUES ($1,$2,$3,$4,$5,$6,$7)`, operation.ID, index, result.DeviceID,
result.Status, result.ErrorCode, result.Message, result.Generation); err != nil {
return ControlBatchOperation{}, errors.New("insert postgres Control API batch item")
}
}
body, err := json.Marshal(operation)
if err != nil {
return ControlBatchOperation{}, errors.New("encode postgres Control API batch operation")
}
receipt = controlReceipt{
Status: 202, Body: body,
Location: "/api/v1/operations/" + url.PathEscape(operation.ID),
TraceID: operation.TraceID, CreatedAt: now,
}
if err := writeControlReceipt(ctx, tx, request.Scope, receipt); err != nil {
return ControlBatchOperation{}, err
}
if err := tx.Commit(); err != nil {
return ControlBatchOperation{}, errors.New("commit postgres Control API batch")
}
return operation, nil
}
func lockControlBatchDevices(
ctx context.Context, tx *sql.Tx, tenantID, siteID string, deviceIDs map[string]int,
) error {
ordered := make([]string, 0, len(deviceIDs))
for deviceID := range deviceIDs {
ordered = append(ordered, deviceID)
}
sort.Strings(ordered)
for _, deviceID := range ordered {
var locked string
err := tx.QueryRowContext(ctx, `SELECT id FROM sense.devices
WHERE tenant_id = $1 AND site_id = $2 AND id = $3 FOR UPDATE`,
tenantID, siteID, deviceID).Scan(&locked)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return errors.New("lock postgres Control API batch devices")
}
}
return nil
}
func controlBatchError(err error) (code, status, message string) {
switch {
case errors.Is(err, ErrNotFound):
return "not_found", "rejected", "device was not found"
case errors.Is(err, ErrETagMismatch):
return "etag_mismatch", "rejected", "device ETag does not match"
case errors.Is(err, ErrAreaPolicyDenied):
return "area_policy_denied", "rejected", "Area policy denies this change"
case errors.Is(err, ErrAreaPolicyUnavailable), errors.Is(err, ErrAreaPolicyInvalid):
return "area_policy_unavailable", "failed", "Area policy is unavailable"
case errors.Is(err, ErrQuotaProjectionUnavailable):
return "quota_projection_unavailable", "failed", "Site quota is unavailable"
case errors.Is(err, ErrQuotaProjectionInvalid):
return "quota_projection_invalid", "failed", "Site quota is invalid"
}
var quotaError *device.QuotaExceededError
if errors.As(err, &quotaError) {
return "quota_exceeded", "rejected", "Site video channel quota is exceeded"
}
return "service_unavailable", "failed", "device change could not be accepted"
}
func (s *Postgres) GetControlOperation(
ctx context.Context, tenantID, operationID string,
) (ControlBatchOperation, error) {
var value ControlBatchOperation
var completedAt sql.NullTime
err := s.db.QueryRowContext(ctx, `SELECT id, tenant_id, site_id, status,
submitted_at, completed_at, trace_id
FROM sense.batch_operations WHERE tenant_id = $1 AND id = $2`, tenantID, operationID).
Scan(&value.ID, &value.TenantID, &value.SiteID, &value.Status,
&value.SubmittedAt, &completedAt, &value.TraceID)
if errors.Is(err, sql.ErrNoRows) {
return ControlBatchOperation{}, ErrNotFound
}
if err != nil {
return ControlBatchOperation{}, errors.New("read postgres Control API batch operation")
}
if completedAt.Valid {
point := completedAt.Time.UTC()
value.CompletedAt = &point
}
rows, err := s.db.QueryContext(ctx, `SELECT device_id, status, error_code, message, generation
FROM sense.batch_operation_items WHERE operation_id = $1 ORDER BY ordinal`, operationID)
if err != nil {
return ControlBatchOperation{}, errors.New("list postgres Control API batch results")
}
defer rows.Close()
value.Results = make([]ControlBatchItemResult, 0)
for rows.Next() {
var item ControlBatchItemResult
var code, message sql.NullString
var generation sql.NullInt64
if err := rows.Scan(&item.DeviceID, &item.Status, &code, &message, &generation); err != nil {
return ControlBatchOperation{}, errors.New("scan postgres Control API batch result")
}
if code.Valid {
item.ErrorCode = &code.String
}
if message.Valid {
item.Message = &message.String
}
if generation.Valid {
item.Generation = &generation.Int64
}
value.Results = append(value.Results, item)
}
if err := rows.Err(); err != nil {
return ControlBatchOperation{}, errors.New("iterate postgres Control API batch results")
}
return value, nil
}
func newControlOperationID(now time.Time) (string, error) {
value := make([]byte, 16)
milliseconds := uint64(now.UTC().UnixMilli())
value[0], value[1], value[2] = byte(milliseconds>>40), byte(milliseconds>>32), byte(milliseconds>>24)
value[3], value[4], value[5] = byte(milliseconds>>16), byte(milliseconds>>8), byte(milliseconds)
if _, err := rand.Read(value[6:]); err != nil {
return "", errors.New("generate Control API operation ID")
}
number := new(big.Int).SetBytes(value)
base, remainder := big.NewInt(32), new(big.Int)
const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
encoded := make([]byte, 26)
for index := len(encoded) - 1; index >= 0; index-- {
number.QuoRem(number, base, remainder)
encoded[index] = alphabet[remainder.Int64()]
}
return "op_" + string(encoded), nil
}
+58
View File
@@ -0,0 +1,58 @@
package store
import (
"context"
"errors"
"time"
)
const (
OperationalLeaseOrphanScan = "mediamtx-orphan-scan"
OperationalLeaseOrphanCleanup = "mediamtx-orphan-cleanup"
OrphanOwnedStale = "owned_stale"
OrphanUnowned = "unowned"
)
var ErrOrphanScanNotFound = errors.New("orphan scan not found")
type MediaPathOwnership struct {
PathName string
DeviceID string
CurrentClaim bool
}
type OrphanFinding struct {
PathName string
Classification string
DeviceID string
Deleted bool
}
type OrphanScan struct {
ID string
InstanceID string
ObservedCount int
OwnedStaleCount int
UnownedCount int
SafetyAllowed bool
SafetyReason string
CompletedAt time.Time
ExpiresAt time.Time
Findings []OrphanFinding
}
type OrphanRepository interface {
AcquireOperationalLease(
context.Context, string, string, string, time.Time, time.Duration,
) (bool, error)
ReleaseOperationalLease(context.Context, string, string, string, time.Time) error
ListMediaPathOwnership(context.Context) ([]MediaPathOwnership, error)
SaveOrphanScan(context.Context, OrphanScan, string, string) error
GetOrphanScan(context.Context, string) (OrphanScan, error)
RecordOrphanCleanup(
context.Context, string, string, string, string, string, time.Time,
) error
}
var _ OrphanRepository = (*Postgres)(nil)
+268
View File
@@ -0,0 +1,268 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
const orphanReportRetention = 7 * 24 * time.Hour
func (s *Postgres) AcquireOperationalLease(
ctx context.Context,
name, owner, token string,
_ time.Time,
duration time.Duration,
) (bool, error) {
if strings.TrimSpace(name) == "" || strings.TrimSpace(owner) == "" ||
strings.TrimSpace(token) == "" || duration <= 0 {
return false, errors.New("invalid operational lease")
}
var acquired int
err := s.db.QueryRowContext(ctx, `INSERT INTO sense.operational_leases(
lease_name, owner_id, fencing_token, lease_until, updated_at
) VALUES (
$1, $2, $3,
clock_timestamp() + ($4 * interval '1 second'), clock_timestamp()
)
ON CONFLICT (lease_name) DO UPDATE SET
owner_id = EXCLUDED.owner_id,
fencing_token = EXCLUDED.fencing_token,
lease_until = EXCLUDED.lease_until,
updated_at = EXCLUDED.updated_at
WHERE sense.operational_leases.lease_until <= clock_timestamp()
OR (sense.operational_leases.owner_id = $2
AND sense.operational_leases.fencing_token = $3)
RETURNING 1`, name, owner, token, duration.Seconds()).Scan(&acquired)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, errors.New("acquire postgres operational lease")
}
return acquired == 1, nil
}
func (s *Postgres) ReleaseOperationalLease(
ctx context.Context,
name, owner, token string,
_ time.Time,
) error {
_, err := s.db.ExecContext(ctx, `UPDATE sense.operational_leases
SET lease_until = clock_timestamp(), updated_at = clock_timestamp()
WHERE lease_name = $1 AND owner_id = $2 AND fencing_token = $3`,
name, owner, token)
if err != nil {
return errors.New("release postgres operational lease")
}
return nil
}
func (s *Postgres) ListMediaPathOwnership(ctx context.Context) ([]MediaPathOwnership, error) {
rows, err := s.db.QueryContext(ctx, `SELECT
o.path_name, o.device_id,
EXISTS (
SELECT 1 FROM sense.devices d
WHERE d.id = o.device_id AND d.path_name = o.path_name
AND EXISTS (
SELECT 1 FROM sense.device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture'
)
) AS current_claim
FROM sense.media_path_ownership o
ORDER BY o.path_name`)
if err != nil {
return nil, errors.New("list postgres MediaMTX path ownership")
}
defer rows.Close()
values := make([]MediaPathOwnership, 0)
for rows.Next() {
var value MediaPathOwnership
if err := rows.Scan(&value.PathName, &value.DeviceID, &value.CurrentClaim); err != nil {
return nil, errors.New("scan postgres MediaMTX path ownership")
}
values = append(values, value)
}
if err := rows.Err(); err != nil {
return nil, errors.New("iterate postgres MediaMTX path ownership")
}
return values, nil
}
func (s *Postgres) SaveOrphanScan(
ctx context.Context,
scan OrphanScan,
owner, token string,
) error {
if err := validateOrphanScan(scan); err != nil {
return err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return errors.New("begin postgres orphan scan save")
}
defer tx.Rollback()
var lease int
err = tx.QueryRowContext(ctx, `SELECT 1 FROM sense.operational_leases
WHERE lease_name = $1 AND owner_id = $2 AND fencing_token = $3
AND lease_until > clock_timestamp()
FOR UPDATE`, OperationalLeaseOrphanScan, owner, token).Scan(&lease)
if errors.Is(err, sql.ErrNoRows) {
return ErrOperationalLeaseLost
}
if err != nil {
return errors.New("verify postgres orphan scan lease")
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.orphan_scan_runs(
id, instance_id, observed_count, owned_stale_count, unowned_count,
safety_allowed, safety_reason, completed_at, expires_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
scan.ID, scan.InstanceID, scan.ObservedCount, scan.OwnedStaleCount, scan.UnownedCount,
scan.SafetyAllowed, scan.SafetyReason, scan.CompletedAt, scan.ExpiresAt,
); err != nil {
return errors.New("insert postgres orphan scan")
}
for _, finding := range scan.Findings {
var deviceID any
if finding.DeviceID != "" {
deviceID = finding.DeviceID
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.orphan_scan_findings(
scan_id, path_name, classification, device_id
) VALUES ($1,$2,$3,$4)`, scan.ID, finding.PathName, finding.Classification, deviceID); err != nil {
return errors.New("insert postgres orphan scan finding")
}
}
if _, err := tx.ExecContext(ctx, `UPDATE sense.operational_leases
SET lease_until = clock_timestamp(), updated_at = clock_timestamp()
WHERE lease_name = $1 AND owner_id = $2 AND fencing_token = $3`,
OperationalLeaseOrphanScan, owner, token); err != nil {
return errors.New("release postgres orphan scan lease")
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sense.orphan_scan_runs r
WHERE r.completed_at < $1
AND NOT EXISTS (
SELECT 1 FROM sense.orphan_cleanup_actions a WHERE a.scan_id = r.id
)`, scan.CompletedAt.Add(-orphanReportRetention)); err != nil {
return errors.New("expire postgres orphan scan reports")
}
if err := tx.Commit(); err != nil {
return errors.New("commit postgres orphan scan")
}
return nil
}
func validateOrphanScan(scan OrphanScan) error {
if strings.TrimSpace(scan.ID) == "" || strings.TrimSpace(scan.InstanceID) == "" ||
scan.ObservedCount < 0 || scan.OwnedStaleCount < 0 || scan.UnownedCount < 0 ||
scan.OwnedStaleCount+scan.UnownedCount > scan.ObservedCount ||
strings.TrimSpace(scan.SafetyReason) == "" || !scan.ExpiresAt.After(scan.CompletedAt) {
return errors.New("invalid orphan scan")
}
seen := make(map[string]struct{}, len(scan.Findings))
staleCount, unownedCount := 0, 0
for _, finding := range scan.Findings {
if strings.TrimSpace(finding.PathName) == "" {
return errors.New("invalid orphan finding path")
}
if _, duplicate := seen[finding.PathName]; duplicate {
return errors.New("duplicate orphan finding path")
}
seen[finding.PathName] = struct{}{}
switch finding.Classification {
case OrphanOwnedStale:
staleCount++
if strings.TrimSpace(finding.DeviceID) == "" {
return errors.New("owned stale finding lacks device")
}
case OrphanUnowned:
unownedCount++
if finding.DeviceID != "" {
return errors.New("unowned finding has device")
}
default:
return fmt.Errorf("invalid orphan finding classification %q", finding.Classification)
}
}
if staleCount != scan.OwnedStaleCount || unownedCount != scan.UnownedCount {
return errors.New("orphan scan counts do not match findings")
}
return nil
}
func (s *Postgres) GetOrphanScan(ctx context.Context, id string) (OrphanScan, error) {
var scan OrphanScan
err := s.db.QueryRowContext(ctx, `SELECT
id, instance_id, observed_count, owned_stale_count, unowned_count,
safety_allowed, safety_reason, completed_at, expires_at
FROM sense.orphan_scan_runs WHERE id = $1`, id).Scan(
&scan.ID, &scan.InstanceID, &scan.ObservedCount, &scan.OwnedStaleCount,
&scan.UnownedCount, &scan.SafetyAllowed, &scan.SafetyReason,
&scan.CompletedAt, &scan.ExpiresAt,
)
if errors.Is(err, sql.ErrNoRows) {
return OrphanScan{}, ErrOrphanScanNotFound
}
if err != nil {
return OrphanScan{}, errors.New("read postgres orphan scan")
}
rows, err := s.db.QueryContext(ctx, `SELECT
f.path_name, f.classification, COALESCE(f.device_id, ''),
COALESCE(a.status = 'deleted', false)
FROM sense.orphan_scan_findings f
LEFT JOIN sense.orphan_cleanup_actions a
ON a.scan_id = f.scan_id AND a.path_name = f.path_name
WHERE f.scan_id = $1 ORDER BY f.path_name`, id)
if err != nil {
return OrphanScan{}, errors.New("list postgres orphan scan findings")
}
defer rows.Close()
scan.Findings = make([]OrphanFinding, 0)
for rows.Next() {
var finding OrphanFinding
if err := rows.Scan(
&finding.PathName, &finding.Classification, &finding.DeviceID, &finding.Deleted,
); err != nil {
return OrphanScan{}, errors.New("scan postgres orphan finding")
}
scan.Findings = append(scan.Findings, finding)
}
if err := rows.Err(); err != nil {
return OrphanScan{}, errors.New("iterate postgres orphan findings")
}
return scan, nil
}
func (s *Postgres) RecordOrphanCleanup(
ctx context.Context,
scanID, pathName, actorID, status, errorCode string,
now time.Time,
) error {
if status != "deleted" && status != "failed" {
return errors.New("invalid orphan cleanup status")
}
var storedError any
if status == "failed" {
if strings.TrimSpace(errorCode) == "" {
return errors.New("failed orphan cleanup requires an error code")
}
storedError = errorCode
}
_, err := s.db.ExecContext(ctx, `INSERT INTO sense.orphan_cleanup_actions(
scan_id, path_name, classification, actor_id, status, error_code, attempted_at
) VALUES ($1,$2,'owned_stale',$3,$4,$5,$6)
ON CONFLICT (scan_id, path_name) DO UPDATE SET
actor_id = EXCLUDED.actor_id,
status = EXCLUDED.status,
error_code = EXCLUDED.error_code,
attempted_at = EXCLUDED.attempted_at
WHERE sense.orphan_cleanup_actions.status <> 'deleted'`,
scanID, pathName, actorID, status, storedError, now)
if err != nil {
return errors.New("record postgres orphan cleanup result")
}
return nil
}
+933
View File
@@ -0,0 +1,933 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"yovision/sense/internal/device"
)
// Postgres persists Sense state in the sense schema and consumes only Bell's
// versioned quota and Area views. Migrations are installed out of process.
type Postgres struct {
db *sql.DB
}
func OpenPostgres(ctx context.Context, dsn string) (*Postgres, error) {
configuration, err := pgx.ParseConfig(dsn)
if err != nil {
return nil, errors.New("invalid postgres DSN")
}
if configuration.RuntimeParams == nil {
configuration.RuntimeParams = make(map[string]string)
}
configuration.RuntimeParams["application_name"] = "yovision-sense"
db := stdlib.OpenDB(*configuration)
db.SetMaxOpenConns(16)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(30 * time.Minute)
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, errors.New("connect postgres database")
}
store := &Postgres{db: db}
if err := store.verifySchemaAndPrivileges(ctx); err != nil {
db.Close()
return nil, err
}
return store, nil
}
func (s *Postgres) Close() error {
return s.db.Close()
}
func (s *Postgres) verifySchemaAndPrivileges(ctx context.Context) error {
var version sql.NullInt64
if err := s.db.QueryRowContext(ctx,
`SELECT MAX(version) FROM sense.schema_migrations`).Scan(&version); err != nil || !version.Valid || version.Int64 < 5 {
return errors.New("postgres sense schema migration v5 is required")
}
var canReadQuotaView, canWriteQuotaView, canReadSiteSource, canWriteSiteSource bool
var canReadAreaView, canWriteAreaView, canReadAreaSource, canWriteAreaSource bool
if err := s.db.QueryRowContext(ctx, `SELECT
has_table_privilege(current_user, 'bell.site_quota_v1', 'SELECT'),
has_table_privilege(current_user, 'bell.site_quota_v1', 'INSERT,UPDATE,DELETE'),
has_table_privilege(current_user, 'bell.sites', 'SELECT'),
has_table_privilege(current_user, 'bell.sites', 'INSERT,UPDATE,DELETE'),
has_table_privilege(current_user, 'bell.area_policy_v1', 'SELECT'),
has_table_privilege(current_user, 'bell.area_policy_v1', 'INSERT,UPDATE,DELETE'),
has_table_privilege(current_user, 'bell.areas', 'SELECT'),
has_table_privilege(current_user, 'bell.areas', 'INSERT,UPDATE,DELETE')`).
Scan(
&canReadQuotaView, &canWriteQuotaView, &canReadSiteSource, &canWriteSiteSource,
&canReadAreaView, &canWriteAreaView, &canReadAreaSource, &canWriteAreaSource,
); err != nil {
return errors.New("verify postgres Bell projection privileges")
}
if !canReadQuotaView || canWriteQuotaView || canReadSiteSource || canWriteSiteSource ||
!canReadAreaView || canWriteAreaView || canReadAreaSource || canWriteAreaSource {
return errors.New("postgres role violates Bell projection privilege boundary")
}
var canUseReceipts, canUseOperations, canUseOperationItems bool
var publicReceipts, publicOperations, publicOperationItems bool
if err := s.db.QueryRowContext(ctx, `SELECT
has_table_privilege(current_user, 'sense.control_idempotency_receipts', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege(current_user, 'sense.batch_operations', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege(current_user, 'sense.batch_operation_items', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege('public', 'sense.control_idempotency_receipts', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege('public', 'sense.batch_operations', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege('public', 'sense.batch_operation_items', 'SELECT,INSERT,UPDATE,DELETE')`).Scan(
&canUseReceipts, &canUseOperations, &canUseOperationItems,
&publicReceipts, &publicOperations, &publicOperationItems,
); err != nil {
return errors.New("verify postgres Control API state privileges")
}
if !canUseReceipts || !canUseOperations || !canUseOperationItems ||
publicReceipts || publicOperations || publicOperationItems {
return errors.New("postgres role violates Control API state privilege boundary")
}
var canUseOwnership, canUseLeases, canUseScans, canUseFindings, canUseActions bool
var publicOwnership, publicLeases, publicScans, publicFindings, publicActions bool
if err := s.db.QueryRowContext(ctx, `SELECT
has_table_privilege(current_user, 'sense.media_path_ownership', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege(current_user, 'sense.operational_leases', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege(current_user, 'sense.orphan_scan_runs', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege(current_user, 'sense.orphan_scan_findings', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege(current_user, 'sense.orphan_cleanup_actions', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege('public', 'sense.media_path_ownership', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege('public', 'sense.operational_leases', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege('public', 'sense.orphan_scan_runs', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege('public', 'sense.orphan_scan_findings', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege('public', 'sense.orphan_cleanup_actions', 'SELECT,INSERT,UPDATE,DELETE')`).Scan(
&canUseOwnership, &canUseLeases, &canUseScans, &canUseFindings, &canUseActions,
&publicOwnership, &publicLeases, &publicScans, &publicFindings, &publicActions,
); err != nil {
return errors.New("verify postgres reconciliation safety privileges")
}
if !canUseOwnership || !canUseLeases || !canUseScans || !canUseFindings || !canUseActions ||
publicOwnership || publicLeases || publicScans || publicFindings || publicActions {
return errors.New("postgres role violates reconciliation safety privilege boundary")
}
return nil
}
func (s *Postgres) CreateDevice(ctx context.Context, value device.Device) error {
if value.Generation == 0 {
value.Generation = 1
}
if value.ActualState == "" {
value.ActualState = device.ActualPending
}
if value.ResourceVersion == 0 {
value.ResourceVersion = 1
}
if err := value.Validate(); err != nil {
return fmt.Errorf("validate device: %w", err)
}
now := time.Now().UTC()
if value.CreatedAt.IsZero() {
value.CreatedAt = now
}
value.UpdatedAt = now
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return errors.New("begin postgres create device")
}
defer tx.Rollback()
areaVersion, err := checkPostgresAreaPolicy(
ctx, tx, value.TenantID, value.SiteID, value.AreaID,
value.HasCapability(device.CapabilityVideoCapture), now,
)
if err != nil {
return err
}
var quotaVersion int64
if value.ConsumesVideoChannel() {
version, quotaErr := checkPostgresVideoQuota(ctx, tx, value.TenantID, value.SiteID, now)
if quotaErr != nil {
return quotaErr
}
quotaVersion = version
}
_, err = tx.ExecContext(ctx, `INSERT INTO sense.devices(
id, tenant_id, site_id, area_id, serial_number, name, modality,
desired_state, actual_state, endpoint_ref, credential_ref, profile_token,
path_name, generation, resource_version, quota_source_version, area_policy_source_version,
created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
value.ID, value.TenantID, value.SiteID, value.AreaID, value.SerialNumber, value.Name,
value.Modality, value.DesiredState, value.ActualState, value.EndpointRef,
value.CredentialRef, value.ProfileToken, value.PathName, value.Generation,
value.ResourceVersion, nullableVersion(quotaVersion), areaVersion,
value.CreatedAt, value.UpdatedAt)
if err != nil {
return errors.New("insert postgres device")
}
for _, capability := range sortedCapabilities(value.Capabilities) {
if _, err := tx.ExecContext(ctx,
`INSERT INTO sense.device_capabilities(device_id, capability) VALUES ($1, $2)`,
value.ID, capability); err != nil {
return errors.New("insert postgres device capability")
}
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.reconcile_state(device_id, updated_at)
VALUES ($1, $2)`, value.ID, now); err != nil {
return errors.New("insert postgres reconcile state")
}
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
EventType: "device.created", TenantID: value.TenantID, SiteID: value.SiteID,
DeviceID: value.ID, Generation: value.Generation,
QuotaSourceVersion: quotaVersion, AreaPolicySourceVersion: areaVersion,
OccurredAt: now,
Payload: map[string]any{
"kind": "device_created", "area_id": value.AreaID,
"modality": value.Modality, "capabilities": sortedCapabilities(value.Capabilities),
"desired_state": value.DesiredState,
},
}); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return errors.New("commit postgres create device")
}
return nil
}
func checkPostgresAreaPolicy(
ctx context.Context,
tx *sql.Tx,
tenantID, siteID, areaID string,
imaging bool,
now time.Time,
) (int64, error) {
if strings.TrimSpace(areaID) == "" {
return 0, areaPolicyUnavailable()
}
// Area projection observation is serialized before the Site quota lock.
// No admission path acquires these locks in the opposite order.
if _, err := tx.ExecContext(ctx,
`SELECT pg_advisory_xact_lock(hashtext($1), hashtext('area:' || $2))`, tenantID, areaID); err != nil {
return 0, errors.New("lock postgres Area admission")
}
var capturePolicy string
var sourceVersion int64
var sourceUpdatedAt time.Time
err := tx.QueryRowContext(ctx, `SELECT capture_policy, source_version, source_updated_at
FROM bell.area_policy_v1
WHERE tenant_id = $1 AND site_id = $2 AND area_id = $3`, tenantID, siteID, areaID).
Scan(&capturePolicy, &sourceVersion, &sourceUpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return 0, areaPolicyUnavailable()
}
if err != nil {
return 0, areaPolicyUnavailable()
}
if (capturePolicy != "video_allowed" && capturePolicy != "non_imaging_only") ||
sourceVersion < 1 || sourceUpdatedAt.IsZero() {
return 0, areaPolicyInvalid()
}
var previous sql.NullInt64
err = tx.QueryRowContext(ctx, `SELECT source_version
FROM sense.area_policy_projection_state
WHERE tenant_id = $1 AND site_id = $2 AND area_id = $3`, tenantID, siteID, areaID).
Scan(&previous)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return 0, errors.New("read postgres Area projection state")
}
if previous.Valid && sourceVersion < previous.Int64 {
return 0, areaPolicyInvalid()
}
if imaging && capturePolicy == "non_imaging_only" {
return 0, areaPolicyDenied()
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.area_policy_projection_state(
tenant_id, site_id, area_id, source_version, synced_at
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (tenant_id, site_id, area_id) DO UPDATE SET
source_version = EXCLUDED.source_version,
synced_at = EXCLUDED.synced_at`, tenantID, siteID, areaID, sourceVersion, now); err != nil {
return 0, errors.New("record postgres Area projection state")
}
return sourceVersion, nil
}
func checkPostgresVideoQuota(
ctx context.Context,
tx *sql.Tx,
tenantID, siteID string,
now time.Time,
) (int64, error) {
// A transaction-scoped lock shared by all Sense instances makes count +
// write atomic per logical site without locking Bell-owned rows.
if _, err := tx.ExecContext(ctx,
`SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, tenantID, siteID); err != nil {
return 0, errors.New("lock postgres site quota admission")
}
var limit int
var sourceVersion int64
var sourceUpdatedAt time.Time
err := tx.QueryRowContext(ctx, `SELECT max_video_channels, source_version, source_updated_at
FROM bell.site_quota_v1 WHERE tenant_id = $1 AND site_id = $2`, tenantID, siteID).
Scan(&limit, &sourceVersion, &sourceUpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return 0, projectionUnavailable()
}
if err != nil {
return 0, projectionUnavailable()
}
if limit < 1 || limit > device.MaximumVideoChannels || sourceVersion < 1 || sourceUpdatedAt.IsZero() {
return 0, projectionInvalid()
}
var previous sql.NullInt64
err = tx.QueryRowContext(ctx, `SELECT source_version
FROM sense.site_quota_projection_state WHERE tenant_id = $1 AND site_id = $2`,
tenantID, siteID).Scan(&previous)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return 0, errors.New("read postgres quota projection state")
}
if previous.Valid && sourceVersion < previous.Int64 {
return 0, projectionInvalid()
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.site_quota_projection_state(
tenant_id, site_id, source_version, synced_at
) VALUES ($1, $2, $3, $4)
ON CONFLICT (tenant_id, site_id) DO UPDATE SET
source_version = EXCLUDED.source_version,
synced_at = EXCLUDED.synced_at`, tenantID, siteID, sourceVersion, now); err != nil {
return 0, errors.New("record postgres quota projection state")
}
var current int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*)
FROM sense.devices d
JOIN sense.device_capabilities c ON c.device_id = d.id
WHERE d.tenant_id = $1 AND d.site_id = $2
AND d.desired_state = 'enabled'
AND c.capability = 'video_capture'`, tenantID, siteID).Scan(&current); err != nil {
return 0, errors.New("count postgres site video channels")
}
if current >= limit {
return 0, &device.QuotaExceededError{TenantID: tenantID, SiteID: siteID, Limit: limit}
}
return sourceVersion, nil
}
func (s *Postgres) SetDesiredState(ctx context.Context, id string, desired device.DesiredState) error {
if desired != device.DesiredEnabled && desired != device.DesiredDisabled {
return fmt.Errorf("invalid desired state %q", desired)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return errors.New("begin postgres desired-state update")
}
defer tx.Rollback()
var tenantID, siteID, endpointRef, pathName string
var areaID sql.NullString
var current device.DesiredState
var generation int64
var storedQuotaVersion, storedAreaVersion sql.NullInt64
err = tx.QueryRowContext(ctx, `SELECT tenant_id, site_id, area_id, desired_state,
endpoint_ref, path_name, generation, quota_source_version, area_policy_source_version
FROM sense.devices WHERE id = $1 FOR UPDATE`, id).
Scan(
&tenantID, &siteID, &areaID, &current, &endpointRef, &pathName,
&generation, &storedQuotaVersion, &storedAreaVersion,
)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return errors.New("read postgres device desired state")
}
if current == desired {
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
EventType: "device.desired_state.accepted", TenantID: tenantID, SiteID: siteID,
DeviceID: id, Generation: generation,
QuotaSourceVersion: storedQuotaVersion.Int64,
AreaPolicySourceVersion: storedAreaVersion.Int64,
OccurredAt: time.Now().UTC(),
Payload: map[string]any{
"kind": "desired_state_accepted", "previous_desired_state": current,
"desired_state": desired, "changed": false,
},
}); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return errors.New("commit postgres no-op desired-state audit")
}
return nil
}
var quotaVersion, areaVersion int64
if desired == device.DesiredEnabled {
var hasVideo bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(
SELECT 1 FROM sense.device_capabilities
WHERE device_id = $1 AND capability = 'video_capture'
)`, id).Scan(&hasVideo); err != nil {
return errors.New("read postgres video capability")
}
if hasVideo {
if strings.TrimSpace(endpointRef) == "" || strings.TrimSpace(pathName) == "" {
return errors.New("enabled video devices require endpoint ref and path name")
}
version, areaErr := checkPostgresAreaPolicy(
ctx, tx, tenantID, siteID, areaID.String, true, time.Now().UTC(),
)
if areaErr != nil {
return areaErr
}
areaVersion = version
version, quotaErr := checkPostgresVideoQuota(ctx, tx, tenantID, siteID, time.Now().UTC())
if quotaErr != nil {
return quotaErr
}
quotaVersion = version
}
}
now := time.Now().UTC()
var updatedQuotaVersion, updatedAreaVersion sql.NullInt64
err = tx.QueryRowContext(ctx, `UPDATE sense.devices SET
desired_state = $1, actual_state = 'pending', generation = generation + 1,
resource_version = resource_version + 1,
quota_source_version = COALESCE($2, quota_source_version),
area_policy_source_version = COALESCE($3, area_policy_source_version),
updated_at = $4
WHERE id = $5
RETURNING generation, quota_source_version, area_policy_source_version`,
desired, nullableVersion(quotaVersion), nullableVersion(areaVersion), now, id).
Scan(&generation, &updatedQuotaVersion, &updatedAreaVersion)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return errors.New("update postgres desired state")
}
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
lease_owner = NULL, lease_token = NULL, lease_until = NULL, updated_at = $1
WHERE device_id = $2`, now, id); err != nil {
return errors.New("reset postgres reconcile state")
}
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
EventType: "device.desired_state.accepted", TenantID: tenantID, SiteID: siteID,
DeviceID: id, Generation: generation,
QuotaSourceVersion: updatedQuotaVersion.Int64,
AreaPolicySourceVersion: updatedAreaVersion.Int64,
OccurredAt: now,
Payload: map[string]any{
"kind": "desired_state_accepted", "previous_desired_state": current,
"desired_state": desired, "changed": true,
},
}); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return errors.New("commit postgres desired-state update")
}
return nil
}
func (s *Postgres) GetDevice(ctx context.Context, id string) (device.Device, error) {
value, err := scanPostgresDevice(s.db.QueryRowContext(ctx, postgresDeviceSelect+` WHERE d.id = $1`, id))
if errors.Is(err, sql.ErrNoRows) {
return device.Device{}, ErrNotFound
}
if err != nil {
return device.Device{}, errors.New("get postgres device")
}
value.Capabilities, err = s.capabilities(ctx, value.ID)
if err != nil {
return device.Device{}, err
}
return value, nil
}
func (s *Postgres) ListDueReconcile(ctx context.Context, now time.Time, limit int) ([]ReconcileCandidate, error) {
if limit <= 0 {
return nil, nil
}
rows, err := s.db.QueryContext(ctx, `SELECT `+postgresDeviceColumns+`, r.failure_count, r.next_attempt_at
FROM sense.devices d
JOIN sense.reconcile_state r ON r.device_id = d.id
WHERE EXISTS (SELECT 1 FROM sense.device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture')
AND (r.observed_generation < d.generation
OR (d.desired_state = 'enabled' AND r.failure_count > 0))
AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= $1)
ORDER BY d.updated_at, d.id LIMIT $2`, now, limit)
if err != nil {
return nil, errors.New("list postgres due reconcile devices")
}
defer rows.Close()
values := make([]ReconcileCandidate, 0)
for rows.Next() {
var candidate ReconcileCandidate
var areaID sql.NullString
var quotaVersion, areaVersion sql.NullInt64
var nextAttempt sql.NullTime
if err := rows.Scan(
&candidate.Device.ID, &candidate.Device.TenantID, &candidate.Device.SiteID,
&areaID, &candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality,
&candidate.Device.DesiredState, &candidate.Device.ActualState,
&candidate.Device.EndpointRef, &candidate.Device.CredentialRef,
&candidate.Device.ProfileToken, &candidate.Device.PathName,
&candidate.Device.Generation, &candidate.Device.ResourceVersion,
&quotaVersion, &areaVersion,
&candidate.Device.CreatedAt, &candidate.Device.UpdatedAt,
&candidate.FailureCount, &nextAttempt,
); err != nil {
return nil, errors.New("scan postgres due reconcile device")
}
candidate.Device.AreaID = areaID.String
candidate.Device.QuotaSourceVersion = quotaVersion.Int64
candidate.Device.AreaPolicySourceVersion = areaVersion.Int64
if nextAttempt.Valid {
value := nextAttempt.Time
candidate.NextAttempt = &value
}
values = append(values, candidate)
}
if err := rows.Err(); err != nil {
return nil, errors.New("iterate postgres due reconcile devices")
}
for index := range values {
values[index].Device.Capabilities, err = s.capabilities(ctx, values[index].Device.ID)
if err != nil {
return nil, err
}
}
return values, nil
}
func (s *Postgres) ClaimDueReconcile(
ctx context.Context,
claim ReconcileClaim,
) ([]ReconcileCandidate, error) {
if claim.Limit <= 0 {
return nil, nil
}
if strings.TrimSpace(claim.Owner) == "" || strings.TrimSpace(claim.Token) == "" ||
claim.LeaseDuration <= 0 {
return nil, errors.New("invalid postgres reconcile claim")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, errors.New("begin postgres reconcile claim")
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx, `SELECT `+postgresDeviceColumns+`, r.failure_count, r.next_attempt_at
FROM sense.devices d
JOIN sense.reconcile_state r ON r.device_id = d.id
WHERE EXISTS (SELECT 1 FROM sense.device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture')
AND (r.observed_generation < d.generation
OR (d.desired_state = 'enabled' AND r.failure_count > 0))
AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= clock_timestamp())
AND (r.lease_until IS NULL OR r.lease_until <= clock_timestamp())
ORDER BY d.updated_at, d.id
FOR UPDATE OF r SKIP LOCKED
LIMIT $1`, claim.Limit)
if err != nil {
return nil, errors.New("select postgres reconcile claims")
}
values := make([]ReconcileCandidate, 0)
for rows.Next() {
var candidate ReconcileCandidate
var areaID sql.NullString
var quotaVersion, areaVersion sql.NullInt64
var nextAttempt sql.NullTime
if err := rows.Scan(
&candidate.Device.ID, &candidate.Device.TenantID, &candidate.Device.SiteID,
&areaID, &candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality,
&candidate.Device.DesiredState, &candidate.Device.ActualState,
&candidate.Device.EndpointRef, &candidate.Device.CredentialRef,
&candidate.Device.ProfileToken, &candidate.Device.PathName,
&candidate.Device.Generation, &candidate.Device.ResourceVersion,
&quotaVersion, &areaVersion,
&candidate.Device.CreatedAt, &candidate.Device.UpdatedAt,
&candidate.FailureCount, &nextAttempt,
); err != nil {
rows.Close()
return nil, errors.New("scan postgres reconcile claim")
}
candidate.Device.AreaID = areaID.String
candidate.Device.QuotaSourceVersion = quotaVersion.Int64
candidate.Device.AreaPolicySourceVersion = areaVersion.Int64
if nextAttempt.Valid {
value := nextAttempt.Time
candidate.NextAttempt = &value
}
values = append(values, candidate)
}
if err := rows.Close(); err != nil {
return nil, errors.New("close postgres reconcile claims")
}
if err := rows.Err(); err != nil {
return nil, errors.New("iterate postgres reconcile claims")
}
for _, candidate := range values {
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
lease_owner = $1, lease_token = $2,
lease_until = clock_timestamp() + ($3 * interval '1 second'),
updated_at = clock_timestamp()
WHERE device_id = $4`,
claim.Owner, claim.Token, claim.LeaseDuration.Seconds(), candidate.Device.ID,
); err != nil {
return nil, errors.New("persist postgres reconcile claim")
}
}
if err := tx.Commit(); err != nil {
return nil, errors.New("commit postgres reconcile claim")
}
for index := range values {
values[index].Device.Capabilities, err = s.capabilities(ctx, values[index].Device.ID)
if err != nil {
return nil, err
}
}
return values, nil
}
func (s *Postgres) RenewReconcileLease(
ctx context.Context,
id, owner, token string,
now time.Time,
duration time.Duration,
) (bool, error) {
if strings.TrimSpace(owner) == "" || strings.TrimSpace(token) == "" || duration <= 0 {
return false, errors.New("invalid postgres reconcile lease renewal")
}
result, err := s.db.ExecContext(ctx, `UPDATE sense.reconcile_state SET
lease_until = clock_timestamp() + ($1 * interval '1 second'),
updated_at = clock_timestamp()
WHERE device_id = $2 AND lease_owner = $3 AND lease_token = $4
AND lease_until > clock_timestamp()`, duration.Seconds(), id, owner, token)
if err != nil {
return false, errors.New("renew postgres reconcile lease")
}
affected, err := result.RowsAffected()
if err != nil {
return false, errors.New("read postgres reconcile lease renewal")
}
return affected == 1, nil
}
func (s *Postgres) CompleteReconcile(
ctx context.Context,
id string,
generation int64,
owner, token string,
now time.Time,
) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return errors.New("begin postgres fenced reconciliation completion")
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
observed_generation = $1, lease_owner = NULL, lease_token = NULL,
lease_until = NULL, updated_at = $2
WHERE device_id = $3 AND lease_owner = $4 AND lease_token = $5
AND lease_until > clock_timestamp()`, generation, now, id, owner, token)
if err != nil {
return errors.New("complete postgres fenced reconciliation")
}
affected, err := result.RowsAffected()
if err != nil {
return errors.New("read postgres fenced reconciliation completion")
}
if affected != 1 {
return ErrReconcileLeaseLost
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.media_path_ownership(
path_name, device_id, tenant_id, site_id, first_claimed_at, last_confirmed_at
)
SELECT path_name, id, tenant_id, site_id, $1, $1
FROM sense.devices
WHERE id = $2 AND desired_state = 'enabled' AND btrim(path_name) <> ''
ON CONFLICT (path_name) DO UPDATE SET
device_id = EXCLUDED.device_id,
tenant_id = EXCLUDED.tenant_id,
site_id = EXCLUDED.site_id,
last_confirmed_at = EXCLUDED.last_confirmed_at`, now, id); err != nil {
return errors.New("record postgres MediaMTX path ownership")
}
result, err = tx.ExecContext(ctx, `UPDATE sense.devices
SET actual_state = CASE WHEN desired_state = 'disabled' THEN 'offline' ELSE 'pending' END,
updated_at = $1 WHERE id = $2`, now, id)
if err != nil {
return errors.New("mark postgres fenced device state")
}
if affected, _ := result.RowsAffected(); affected != 1 {
return ErrNotFound
}
if err := tx.Commit(); err != nil {
return errors.New("commit postgres fenced reconciliation completion")
}
return nil
}
func (s *Postgres) FailReconcile(
ctx context.Context,
id string,
failureCount int,
nextAttempt time.Time,
errorCode, owner, token string,
now time.Time,
) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return errors.New("begin postgres fenced reconciliation failure")
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
failure_count = $1, next_attempt_at = $2, last_error_code = $3,
lease_owner = NULL, lease_token = NULL, lease_until = NULL, updated_at = $4
WHERE device_id = $5 AND lease_owner = $6 AND lease_token = $7
AND lease_until > clock_timestamp()`, failureCount, nextAttempt, errorCode, now, id, owner, token)
if err != nil {
return errors.New("persist postgres fenced reconciliation failure")
}
affected, err := result.RowsAffected()
if err != nil {
return errors.New("read postgres fenced reconciliation failure")
}
if affected != 1 {
return ErrReconcileLeaseLost
}
result, err = tx.ExecContext(ctx, `UPDATE sense.devices
SET actual_state = 'failed', updated_at = $1 WHERE id = $2`, now, id)
if err != nil {
return errors.New("mark postgres fenced failed device")
}
if affected, _ := result.RowsAffected(); affected != 1 {
return ErrNotFound
}
if err := tx.Commit(); err != nil {
return errors.New("commit postgres fenced reconciliation failure")
}
return nil
}
func (s *Postgres) ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error) {
if limit <= 0 {
return nil, nil
}
rows, err := s.db.QueryContext(ctx, postgresDeviceSelect+`
WHERE d.desired_state = 'enabled'
AND EXISTS (SELECT 1 FROM sense.device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture')
ORDER BY d.id LIMIT $1`, limit)
if err != nil {
return nil, errors.New("list postgres enabled video devices")
}
defer rows.Close()
values := make([]device.Device, 0)
for rows.Next() {
value, scanErr := scanPostgresDevice(rows)
if scanErr != nil {
return nil, errors.New("scan postgres enabled video device")
}
values = append(values, value)
}
if err := rows.Err(); err != nil {
return nil, errors.New("iterate postgres enabled video devices")
}
for index := range values {
values[index].Capabilities, err = s.capabilities(ctx, values[index].ID)
if err != nil {
return nil, err
}
}
return values, nil
}
func (s *Postgres) MarkReconciled(ctx context.Context, id string, generation int64, now time.Time) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return errors.New("begin postgres reconciled update")
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
observed_generation = $1, updated_at = $2 WHERE device_id = $3`, generation, now, id)
if err != nil {
return errors.New("mark postgres device reconciled")
}
if affected, _ := result.RowsAffected(); affected != 1 {
return ErrNotFound
}
if _, err := tx.ExecContext(ctx, `UPDATE sense.devices
SET actual_state = CASE WHEN desired_state = 'disabled' THEN 'offline' ELSE 'pending' END,
updated_at = $1 WHERE id = $2`, now, id); err != nil {
return errors.New("mark postgres reconciled device pending")
}
if err := tx.Commit(); err != nil {
return errors.New("commit postgres reconciled update")
}
return nil
}
func (s *Postgres) MarkReconcileFailure(
ctx context.Context,
id string,
failureCount int,
nextAttempt time.Time,
errorCode string,
now time.Time,
) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return errors.New("begin postgres reconcile failure update")
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
failure_count = $1, next_attempt_at = $2, last_error_code = $3, updated_at = $4
WHERE device_id = $5`, failureCount, nextAttempt, errorCode, now, id)
if err != nil {
return errors.New("mark postgres reconcile failure")
}
if affected, _ := result.RowsAffected(); affected != 1 {
return ErrNotFound
}
if _, err := tx.ExecContext(ctx, `UPDATE sense.devices
SET actual_state = 'failed', updated_at = $1 WHERE id = $2`, now, id); err != nil {
return errors.New("mark postgres failed device state")
}
if err := tx.Commit(); err != nil {
return errors.New("commit postgres reconcile failure")
}
return nil
}
func (s *Postgres) UpdateActualState(ctx context.Context, id string, state device.ActualState, now time.Time) error {
if state != device.ActualPending && state != device.ActualOnline &&
state != device.ActualOffline && state != device.ActualFailed {
return fmt.Errorf("invalid actual state %q", state)
}
result, err := s.db.ExecContext(ctx, `UPDATE sense.devices
SET actual_state = $1, updated_at = $2 WHERE id = $3`, state, now, id)
if err != nil {
return errors.New("update postgres actual state")
}
if affected, _ := result.RowsAffected(); affected != 1 {
return ErrNotFound
}
return nil
}
func (s *Postgres) RequestReconcile(ctx context.Context, id string, now time.Time) error {
result, err := s.db.ExecContext(ctx, `UPDATE sense.reconcile_state
SET observed_generation = 0, updated_at = $1 WHERE device_id = $2`, now, id)
if err != nil {
return errors.New("request postgres device reconciliation")
}
if affected, _ := result.RowsAffected(); affected != 1 {
return ErrNotFound
}
return nil
}
func (s *Postgres) ConvergenceSnapshot(ctx context.Context) (ConvergenceSnapshot, error) {
rows, err := s.db.QueryContext(ctx, `SELECT
d.id, d.path_name, d.desired_state, d.actual_state, d.generation,
r.observed_generation, r.failure_count, r.next_attempt_at, r.last_error_code
FROM sense.devices d
JOIN sense.reconcile_state r ON r.device_id = d.id
WHERE d.desired_state = 'enabled'
AND EXISTS (SELECT 1 FROM sense.device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture')
ORDER BY d.id`)
if err != nil {
return ConvergenceSnapshot{}, errors.New("query postgres convergence snapshot")
}
defer rows.Close()
snapshot := ConvergenceSnapshot{Devices: make([]DeviceConvergence, 0)}
for rows.Next() {
var value DeviceConvergence
var nextAttempt sql.NullTime
var lastError sql.NullString
if err := rows.Scan(
&value.ID, &value.PathName, &value.DesiredState, &value.ActualState,
&value.Generation, &value.ObservedGeneration, &value.FailureCount,
&nextAttempt, &lastError,
); err != nil {
return ConvergenceSnapshot{}, errors.New("scan postgres convergence snapshot")
}
if nextAttempt.Valid {
point := nextAttempt.Time
value.NextAttemptAt = &point
}
if lastError.Valid {
value.LastErrorCode = lastError.String
}
value.Converged = value.ObservedGeneration == value.Generation &&
value.FailureCount == 0 && value.ActualState == device.ActualOnline
if !value.Converged {
snapshot.Unconverged++
}
snapshot.Devices = append(snapshot.Devices, value)
}
if err := rows.Err(); err != nil {
return ConvergenceSnapshot{}, errors.New("iterate postgres convergence snapshot")
}
snapshot.Total = len(snapshot.Devices)
return snapshot, nil
}
const postgresDeviceColumns = `d.id, d.tenant_id, d.site_id, d.area_id, d.serial_number, d.name, d.modality,
d.desired_state, d.actual_state, d.endpoint_ref, d.credential_ref, d.profile_token,
d.path_name, d.generation, d.resource_version, d.quota_source_version, d.area_policy_source_version,
d.created_at, d.updated_at`
const postgresDeviceSelect = `SELECT ` + postgresDeviceColumns + ` FROM sense.devices d`
func scanPostgresDevice(row scanner) (device.Device, error) {
var value device.Device
var areaID sql.NullString
var quotaVersion, areaVersion sql.NullInt64
err := row.Scan(
&value.ID, &value.TenantID, &value.SiteID, &areaID, &value.SerialNumber,
&value.Name, &value.Modality, &value.DesiredState, &value.ActualState,
&value.EndpointRef, &value.CredentialRef, &value.ProfileToken, &value.PathName,
&value.Generation, &value.ResourceVersion, &quotaVersion, &areaVersion,
&value.CreatedAt, &value.UpdatedAt,
)
value.AreaID = areaID.String
value.QuotaSourceVersion = quotaVersion.Int64
value.AreaPolicySourceVersion = areaVersion.Int64
return value, err
}
func (s *Postgres) capabilities(ctx context.Context, id string) ([]device.Capability, error) {
rows, err := s.db.QueryContext(ctx, `SELECT capability
FROM sense.device_capabilities WHERE device_id = $1 ORDER BY capability`, id)
if err != nil {
return nil, errors.New("list postgres device capabilities")
}
defer rows.Close()
values := make([]device.Capability, 0)
for rows.Next() {
var value device.Capability
if err := rows.Scan(&value); err != nil {
return nil, errors.New("scan postgres device capability")
}
values = append(values, value)
}
if err := rows.Err(); err != nil {
return nil, errors.New("iterate postgres device capabilities")
}
sort.Slice(values, func(i, j int) bool { return values[i] < values[j] })
return values, nil
}
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
package store
import (
"context"
"errors"
"fmt"
"strings"
"time"
"yovision/sense/internal/device"
)
const (
DriverSQLite = "sqlite"
DriverPostgres = "postgres"
)
var (
ErrQuotaProjectionUnavailable = errors.New("quota projection unavailable")
ErrQuotaProjectionInvalid = errors.New("quota projection invalid")
ErrAreaPolicyUnavailable = errors.New("area policy unavailable")
ErrAreaPolicyInvalid = errors.New("area policy invalid")
ErrAreaPolicyDenied = errors.New("area policy denies imaging device")
ErrReconcileLeaseLost = errors.New("reconcile lease lost")
ErrOperationalLeaseLost = errors.New("operational lease lost")
)
// Repository is the storage boundary used by the Sense process. SQLite stays
// available for M1 development; PostgreSQL implements the M2 production path.
type Repository interface {
Close() error
CreateDevice(context.Context, device.Device) error
SetDesiredState(context.Context, string, device.DesiredState) error
GetDevice(context.Context, string) (device.Device, error)
ListDueReconcile(context.Context, time.Time, int) ([]ReconcileCandidate, error)
ClaimDueReconcile(context.Context, ReconcileClaim) ([]ReconcileCandidate, error)
RenewReconcileLease(context.Context, string, string, string, time.Time, time.Duration) (bool, error)
CompleteReconcile(context.Context, string, int64, string, string, time.Time) error
FailReconcile(context.Context, string, int, time.Time, string, string, string, time.Time) error
ListEnabledVideoDevices(context.Context, int) ([]device.Device, error)
MarkReconciled(context.Context, string, int64, time.Time) error
MarkReconcileFailure(context.Context, string, int, time.Time, string, time.Time) error
UpdateActualState(context.Context, string, device.ActualState, time.Time) error
RequestReconcile(context.Context, string, time.Time) error
ConvergenceSnapshot(context.Context) (ConvergenceSnapshot, error)
}
// ReconcileClaim identifies one short-lived batch claim. Token is unique per
// run and fences a worker whose lease expired and was acquired by another
// process. SQLite accepts the shape but remains explicitly single-process.
type ReconcileClaim struct {
Owner string
Token string
Now time.Time
LeaseDuration time.Duration
Limit int
}
func OpenRepository(ctx context.Context, driver, dsn string) (Repository, error) {
switch strings.ToLower(strings.TrimSpace(driver)) {
case "", DriverSQLite:
return OpenSQLite(ctx, dsn)
case DriverPostgres:
return OpenPostgres(ctx, dsn)
default:
return nil, fmt.Errorf("unsupported database driver %q", driver)
}
}
type quotaProjectionError struct {
kind error
}
func (e *quotaProjectionError) Error() string { return e.kind.Error() }
func (e *quotaProjectionError) Unwrap() error { return e.kind }
func projectionUnavailable() error {
return &quotaProjectionError{kind: ErrQuotaProjectionUnavailable}
}
func projectionInvalid() error {
return &quotaProjectionError{kind: ErrQuotaProjectionInvalid}
}
type areaPolicyError struct {
kind error
}
func (e *areaPolicyError) Error() string { return e.kind.Error() }
func (e *areaPolicyError) Unwrap() error { return e.kind }
func areaPolicyUnavailable() error {
return &areaPolicyError{kind: ErrAreaPolicyUnavailable}
}
func areaPolicyInvalid() error {
return &areaPolicyError{kind: ErrAreaPolicyInvalid}
}
func areaPolicyDenied() error {
return &areaPolicyError{kind: ErrAreaPolicyDenied}
}
+724
View File
@@ -0,0 +1,724 @@
// Package store persists the Sense desired state and reconciliation progress.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
_ "modernc.org/sqlite"
"yovision/sense/internal/device"
)
var ErrNotFound = errors.New("store record not found")
type ReconcileCandidate struct {
Device device.Device
FailureCount int
NextAttempt *time.Time
}
type DeviceConvergence struct {
ID string `json:"id"`
PathName string `json:"path_name"`
DesiredState device.DesiredState `json:"desired_state"`
ActualState device.ActualState `json:"actual_state"`
Generation int64 `json:"generation"`
ObservedGeneration int64 `json:"observed_generation"`
FailureCount int `json:"failure_count"`
NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`
LastErrorCode string `json:"last_error_code,omitempty"`
Converged bool `json:"converged"`
}
type ConvergenceSnapshot struct {
Total int `json:"total"`
Unconverged int `json:"unconverged"`
Devices []DeviceConvergence `json:"devices"`
}
type SQLite struct {
db *sql.DB
}
func OpenSQLite(ctx context.Context, dsn string) (*SQLite, error) {
if err := ensureSQLiteDirectory(dsn); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// M1 runs one writer per edge instance. A single connection also gives
// deterministic quota transactions and avoids :memory: connection splits.
db.SetMaxOpenConns(1)
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
store := &SQLite{db: db}
if err := store.Migrate(ctx); err != nil {
db.Close()
return nil, err
}
return store, nil
}
func (s *SQLite) Close() error {
return s.db.Close()
}
func ensureSQLiteDirectory(dsn string) error {
if !strings.HasPrefix(dsn, "file:") {
return nil
}
path := strings.TrimPrefix(dsn, "file:")
path = strings.SplitN(path, "?", 2)[0]
if path == "" || path == ":memory:" || strings.HasPrefix(path, ":memory:") {
return nil
}
directory := filepath.Dir(filepath.FromSlash(path))
if directory == "." {
return nil
}
if err := os.MkdirAll(directory, 0o750); err != nil {
return fmt.Errorf("create sqlite directory: %w", err)
}
return nil
}
func (s *SQLite) Migrate(ctx context.Context) error {
if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil {
return fmt.Errorf("enable sqlite foreign keys: %w", err)
}
if _, err := s.db.ExecContext(ctx, `PRAGMA busy_timeout = 5000`); err != nil {
return fmt.Errorf("configure sqlite busy timeout: %w", err)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration: %w", err)
}
defer tx.Rollback()
for _, statement := range migrationStatements {
if _, err := tx.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("apply sqlite migration: %w", err)
}
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO sense_schema_migrations(version, applied_at)
VALUES (1, ?)
ON CONFLICT(version) DO NOTHING`, formatTime(time.Now())); err != nil {
return fmt.Errorf("record sqlite migration: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit sqlite migration: %w", err)
}
return nil
}
var migrationStatements = []string{
`CREATE TABLE IF NOT EXISTS sense_schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS sense_sites (
tenant_id TEXT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL,
max_video_channels INTEGER NOT NULL DEFAULT 16 CHECK (max_video_channels BETWEEN 1 AND 128),
PRIMARY KEY (tenant_id, id)
)`,
`CREATE TABLE IF NOT EXISTS sense_devices (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
site_id TEXT NOT NULL,
serial_number TEXT NOT NULL,
name TEXT NOT NULL,
modality TEXT NOT NULL,
desired_state TEXT NOT NULL CHECK (desired_state IN ('disabled', 'enabled')),
actual_state TEXT NOT NULL CHECK (actual_state IN ('pending', 'online', 'offline', 'failed')),
endpoint_ref TEXT NOT NULL DEFAULT '',
credential_ref TEXT NOT NULL DEFAULT '',
path_name TEXT NOT NULL DEFAULT '',
generation INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (tenant_id, site_id, serial_number),
FOREIGN KEY (tenant_id, site_id) REFERENCES sense_sites(tenant_id, id)
)`,
`CREATE TABLE IF NOT EXISTS sense_device_capabilities (
device_id TEXT NOT NULL,
capability TEXT NOT NULL,
PRIMARY KEY (device_id, capability),
FOREIGN KEY (device_id) REFERENCES sense_devices(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS sense_reconcile_state (
device_id TEXT PRIMARY KEY,
failure_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT,
last_error_code TEXT,
observed_generation INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
FOREIGN KEY (device_id) REFERENCES sense_devices(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS sense_devices_site_state_idx
ON sense_devices(tenant_id, site_id, desired_state)`,
`CREATE UNIQUE INDEX IF NOT EXISTS sense_devices_path_name_idx
ON sense_devices(path_name) WHERE path_name <> ''`,
`CREATE INDEX IF NOT EXISTS sense_reconcile_due_idx
ON sense_reconcile_state(next_attempt_at)`,
}
func (s *SQLite) EnsureSite(ctx context.Context, site device.Site) error {
site.ApplyDefaults()
if err := site.Validate(); err != nil {
return fmt.Errorf("validate site: %w", err)
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO sense_sites(tenant_id, id, name, max_video_channels)
VALUES (?, ?, ?, ?)
ON CONFLICT(tenant_id, id) DO UPDATE SET
name = excluded.name,
max_video_channels = excluded.max_video_channels`,
site.TenantID, site.ID, site.Name, site.MaxVideoChannels)
if err != nil {
return fmt.Errorf("ensure site: %w", err)
}
return nil
}
func (s *SQLite) CreateDevice(ctx context.Context, value device.Device) error {
if value.Generation == 0 {
value.Generation = 1
}
if value.ActualState == "" {
value.ActualState = device.ActualPending
}
if err := value.Validate(); err != nil {
return fmt.Errorf("validate device: %w", err)
}
now := time.Now().UTC()
if value.CreatedAt.IsZero() {
value.CreatedAt = now
}
value.UpdatedAt = now
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin create device: %w", err)
}
defer tx.Rollback()
if value.ConsumesVideoChannel() {
if err := checkVideoQuota(ctx, tx, value.TenantID, value.SiteID); err != nil {
return err
}
}
_, err = tx.ExecContext(ctx, `
INSERT INTO sense_devices(
id, tenant_id, site_id, serial_number, name, modality,
desired_state, actual_state, endpoint_ref, credential_ref,
path_name, generation, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
value.ID, value.TenantID, value.SiteID, value.SerialNumber, value.Name,
value.Modality, value.DesiredState, value.ActualState, value.EndpointRef,
value.CredentialRef, value.PathName, value.Generation,
formatTime(value.CreatedAt), formatTime(value.UpdatedAt))
if err != nil {
return fmt.Errorf("insert device: %w", err)
}
for _, capability := range sortedCapabilities(value.Capabilities) {
if _, err := tx.ExecContext(ctx,
`INSERT INTO sense_device_capabilities(device_id, capability) VALUES (?, ?)`,
value.ID, capability); err != nil {
return fmt.Errorf("insert device capability: %w", err)
}
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO sense_reconcile_state(device_id, updated_at)
VALUES (?, ?)`, value.ID, formatTime(now)); err != nil {
return fmt.Errorf("insert reconcile state: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit create device: %w", err)
}
return nil
}
func checkVideoQuota(ctx context.Context, tx *sql.Tx, tenantID, siteID string) error {
var limit int
err := tx.QueryRowContext(ctx,
`SELECT max_video_channels FROM sense_sites WHERE tenant_id = ? AND id = ?`,
tenantID, siteID).Scan(&limit)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("site %s/%s: %w", tenantID, siteID, ErrNotFound)
}
if err != nil {
return fmt.Errorf("read site quota: %w", err)
}
var current int
err = tx.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM sense_devices d
JOIN sense_device_capabilities c ON c.device_id = d.id
WHERE d.tenant_id = ? AND d.site_id = ?
AND d.desired_state = 'enabled'
AND c.capability = 'video_capture'`, tenantID, siteID).Scan(&current)
if err != nil {
return fmt.Errorf("count site video channels: %w", err)
}
if current >= limit {
return &device.QuotaExceededError{TenantID: tenantID, SiteID: siteID, Limit: limit}
}
return nil
}
func (s *SQLite) SetDesiredState(ctx context.Context, id string, desired device.DesiredState) error {
if desired != device.DesiredEnabled && desired != device.DesiredDisabled {
return fmt.Errorf("invalid desired state %q", desired)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin desired-state update: %w", err)
}
defer tx.Rollback()
var tenantID, siteID, endpointRef, pathName string
var current device.DesiredState
err = tx.QueryRowContext(ctx,
`SELECT tenant_id, site_id, desired_state, endpoint_ref, path_name FROM sense_devices WHERE id = ?`, id).
Scan(&tenantID, &siteID, &current, &endpointRef, &pathName)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("read device desired state: %w", err)
}
if current == desired {
return tx.Commit()
}
if desired == device.DesiredEnabled {
var videoCapability int
if err := tx.QueryRowContext(ctx, `
SELECT COUNT(*) FROM sense_device_capabilities
WHERE device_id = ? AND capability = 'video_capture'`, id).Scan(&videoCapability); err != nil {
return fmt.Errorf("read video capability: %w", err)
}
if videoCapability > 0 {
if strings.TrimSpace(endpointRef) == "" || strings.TrimSpace(pathName) == "" {
return fmt.Errorf("enabled video devices require endpoint ref and path name")
}
if err := checkVideoQuota(ctx, tx, tenantID, siteID); err != nil {
return err
}
}
}
result, err := tx.ExecContext(ctx, `
UPDATE sense_devices
SET desired_state = ?, actual_state = 'pending', generation = generation + 1, updated_at = ?
WHERE id = ?`, desired, formatTime(time.Now()), id)
if err != nil {
return fmt.Errorf("update desired state: %w", err)
}
if affected, _ := result.RowsAffected(); affected != 1 {
return ErrNotFound
}
if _, err := tx.ExecContext(ctx, `
UPDATE sense_reconcile_state
SET failure_count = 0, next_attempt_at = NULL, last_error_code = NULL, updated_at = ?
WHERE device_id = ?`, formatTime(time.Now()), id); err != nil {
return fmt.Errorf("reset reconcile state: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit desired-state update: %w", err)
}
return nil
}
func (s *SQLite) GetDevice(ctx context.Context, id string) (device.Device, error) {
row := s.db.QueryRowContext(ctx, deviceSelect+` WHERE d.id = ?`, id)
value, err := scanDevice(row)
if errors.Is(err, sql.ErrNoRows) {
return device.Device{}, ErrNotFound
}
if err != nil {
return device.Device{}, fmt.Errorf("get device: %w", err)
}
capabilities, err := s.capabilities(ctx, value.ID)
if err != nil {
return device.Device{}, err
}
value.Capabilities = capabilities
return value, nil
}
func (s *SQLite) ListDueReconcile(ctx context.Context, now time.Time, limit int) ([]ReconcileCandidate, error) {
if limit <= 0 {
return nil, nil
}
rows, err := s.db.QueryContext(ctx, `SELECT `+deviceColumns+`, r.failure_count, r.next_attempt_at
FROM sense_devices d
JOIN sense_reconcile_state r ON r.device_id = d.id
WHERE EXISTS (
SELECT 1 FROM sense_device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture'
)
AND (r.observed_generation < d.generation
OR (d.desired_state = 'enabled' AND r.failure_count > 0))
AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= ?)
ORDER BY d.updated_at, d.id
LIMIT ?`, formatTime(now), limit)
if err != nil {
return nil, fmt.Errorf("list due reconcile devices: %w", err)
}
candidates := make([]ReconcileCandidate, 0)
for rows.Next() {
var candidate ReconcileCandidate
var createdAt, updatedAt string
var nextAttempt sql.NullString
if err := rows.Scan(
&candidate.Device.ID, &candidate.Device.TenantID, &candidate.Device.SiteID,
&candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality,
&candidate.Device.DesiredState, &candidate.Device.ActualState,
&candidate.Device.EndpointRef, &candidate.Device.CredentialRef,
&candidate.Device.PathName, &candidate.Device.Generation,
&createdAt, &updatedAt, &candidate.FailureCount, &nextAttempt,
); err != nil {
rows.Close()
return nil, fmt.Errorf("scan due reconcile device: %w", err)
}
candidate.Device.CreatedAt, err = parseTime(createdAt)
if err != nil {
rows.Close()
return nil, err
}
candidate.Device.UpdatedAt, err = parseTime(updatedAt)
if err != nil {
rows.Close()
return nil, err
}
if nextAttempt.Valid {
value, parseErr := parseTime(nextAttempt.String)
if parseErr != nil {
rows.Close()
return nil, parseErr
}
candidate.NextAttempt = &value
}
candidates = append(candidates, candidate)
}
if err := rows.Close(); err != nil {
return nil, fmt.Errorf("close due reconcile rows: %w", err)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate due reconcile devices: %w", err)
}
for index := range candidates {
capabilities, err := s.capabilities(ctx, candidates[index].Device.ID)
if err != nil {
return nil, err
}
candidates[index].Device.Capabilities = capabilities
}
return candidates, nil
}
func (s *SQLite) ClaimDueReconcile(ctx context.Context, claim ReconcileClaim) ([]ReconcileCandidate, error) {
// SQLite is retained for one-process M1 development. It deliberately does
// not claim cross-process leases; PostgreSQL is the production M2 boundary.
return s.ListDueReconcile(ctx, claim.Now, claim.Limit)
}
func (s *SQLite) RenewReconcileLease(
ctx context.Context,
_, _, _ string,
_ time.Time,
_ time.Duration,
) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
return true, nil
}
func (s *SQLite) ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error) {
if limit <= 0 {
return nil, nil
}
rows, err := s.db.QueryContext(ctx, deviceSelect+`
WHERE d.desired_state = 'enabled'
AND EXISTS (
SELECT 1 FROM sense_device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture'
)
ORDER BY d.id LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("list enabled video devices: %w", err)
}
values := make([]device.Device, 0)
for rows.Next() {
value, err := scanDevice(rows)
if err != nil {
rows.Close()
return nil, fmt.Errorf("scan enabled video device: %w", err)
}
values = append(values, value)
}
if err := rows.Close(); err != nil {
return nil, fmt.Errorf("close enabled video rows: %w", err)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate enabled video devices: %w", err)
}
for index := range values {
capabilities, err := s.capabilities(ctx, values[index].ID)
if err != nil {
return nil, err
}
values[index].Capabilities = capabilities
}
return values, nil
}
func (s *SQLite) MarkReconciled(ctx context.Context, id string, generation int64, now time.Time) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin reconciled update: %w", err)
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `
UPDATE sense_reconcile_state
SET failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
observed_generation = ?, updated_at = ?
WHERE device_id = ?`, generation, formatTime(now), id)
if err != nil {
return fmt.Errorf("mark device reconciled: %w", err)
}
if affected, _ := result.RowsAffected(); affected != 1 {
return ErrNotFound
}
if _, err := tx.ExecContext(ctx, `
UPDATE sense_devices SET
actual_state = CASE WHEN desired_state = 'disabled' THEN 'offline' ELSE 'pending' END,
updated_at = ? WHERE id = ?`,
formatTime(now), id); err != nil {
return fmt.Errorf("mark reconciled device pending: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit reconciled update: %w", err)
}
return nil
}
func (s *SQLite) CompleteReconcile(
ctx context.Context,
id string,
generation int64,
_, _ string,
now time.Time,
) error {
return s.MarkReconciled(ctx, id, generation, now)
}
func (s *SQLite) MarkReconcileFailure(ctx context.Context, id string, failureCount int, nextAttempt time.Time, errorCode string, now time.Time) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin reconcile failure update: %w", err)
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `
UPDATE sense_reconcile_state
SET failure_count = ?, next_attempt_at = ?, last_error_code = ?, updated_at = ?
WHERE device_id = ?`, failureCount, formatTime(nextAttempt), errorCode, formatTime(now), id)
if err != nil {
return fmt.Errorf("mark reconcile failure: %w", err)
}
if affected, _ := result.RowsAffected(); affected != 1 {
return ErrNotFound
}
if _, err := tx.ExecContext(ctx, `
UPDATE sense_devices SET actual_state = 'failed', updated_at = ? WHERE id = ?`,
formatTime(now), id); err != nil {
return fmt.Errorf("mark failed device state: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit reconcile failure update: %w", err)
}
return nil
}
func (s *SQLite) FailReconcile(
ctx context.Context,
id string,
failureCount int,
nextAttempt time.Time,
errorCode, _, _ string,
now time.Time,
) error {
return s.MarkReconcileFailure(ctx, id, failureCount, nextAttempt, errorCode, now)
}
func (s *SQLite) UpdateActualState(ctx context.Context, id string, state device.ActualState, now time.Time) error {
if state != device.ActualPending && state != device.ActualOnline && state != device.ActualOffline && state != device.ActualFailed {
return fmt.Errorf("invalid actual state %q", state)
}
result, err := s.db.ExecContext(ctx,
`UPDATE sense_devices SET actual_state = ?, updated_at = ? WHERE id = ?`,
state, formatTime(now), id)
if err != nil {
return fmt.Errorf("update actual state: %w", err)
}
if affected, _ := result.RowsAffected(); affected != 1 {
return ErrNotFound
}
return nil
}
// RequestReconcile invalidates the observed generation without changing the
// desired state or retry backoff. Runtime probes use it when MediaMTX loses a
// configured path, including after a MediaMTX process restart.
func (s *SQLite) RequestReconcile(ctx context.Context, id string, now time.Time) error {
result, err := s.db.ExecContext(ctx, `
UPDATE sense_reconcile_state
SET observed_generation = 0, updated_at = ?
WHERE device_id = ?`, formatTime(now), id)
if err != nil {
return fmt.Errorf("request device reconciliation: %w", err)
}
if affected, _ := result.RowsAffected(); affected != 1 {
return ErrNotFound
}
return nil
}
// ConvergenceSnapshot returns only identifiers, state and counters. Endpoint
// and credential references are deliberately excluded from diagnostics.
func (s *SQLite) ConvergenceSnapshot(ctx context.Context) (ConvergenceSnapshot, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT d.id, d.path_name, d.desired_state, d.actual_state, d.generation,
r.observed_generation, r.failure_count, r.next_attempt_at, r.last_error_code
FROM sense_devices d
JOIN sense_reconcile_state r ON r.device_id = d.id
WHERE d.desired_state = 'enabled'
AND EXISTS (
SELECT 1 FROM sense_device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture'
)
ORDER BY d.id`)
if err != nil {
return ConvergenceSnapshot{}, fmt.Errorf("query convergence snapshot: %w", err)
}
defer rows.Close()
snapshot := ConvergenceSnapshot{Devices: make([]DeviceConvergence, 0)}
for rows.Next() {
var value DeviceConvergence
var nextAttempt, lastError sql.NullString
if err := rows.Scan(
&value.ID, &value.PathName, &value.DesiredState, &value.ActualState,
&value.Generation, &value.ObservedGeneration, &value.FailureCount,
&nextAttempt, &lastError,
); err != nil {
return ConvergenceSnapshot{}, fmt.Errorf("scan convergence snapshot: %w", err)
}
if nextAttempt.Valid {
parsed, parseErr := parseTime(nextAttempt.String)
if parseErr != nil {
return ConvergenceSnapshot{}, parseErr
}
value.NextAttemptAt = &parsed
}
if lastError.Valid {
value.LastErrorCode = lastError.String
}
value.Converged = value.ObservedGeneration == value.Generation &&
value.FailureCount == 0 && value.ActualState == device.ActualOnline
if !value.Converged {
snapshot.Unconverged++
}
snapshot.Devices = append(snapshot.Devices, value)
}
if err := rows.Err(); err != nil {
return ConvergenceSnapshot{}, fmt.Errorf("iterate convergence snapshot: %w", err)
}
snapshot.Total = len(snapshot.Devices)
return snapshot, nil
}
const deviceColumns = `d.id, d.tenant_id, d.site_id, d.serial_number, d.name, d.modality,
d.desired_state, d.actual_state, d.endpoint_ref, d.credential_ref,
d.path_name, d.generation, d.created_at, d.updated_at`
const deviceSelect = `SELECT ` + deviceColumns + ` FROM sense_devices d`
type scanner interface {
Scan(dest ...any) error
}
func scanDevice(row scanner) (device.Device, error) {
var value device.Device
var createdAt, updatedAt string
err := row.Scan(
&value.ID, &value.TenantID, &value.SiteID, &value.SerialNumber,
&value.Name, &value.Modality, &value.DesiredState, &value.ActualState,
&value.EndpointRef, &value.CredentialRef, &value.PathName,
&value.Generation, &createdAt, &updatedAt,
)
if err != nil {
return device.Device{}, err
}
value.CreatedAt, err = parseTime(createdAt)
if err != nil {
return device.Device{}, err
}
value.UpdatedAt, err = parseTime(updatedAt)
if err != nil {
return device.Device{}, err
}
return value, nil
}
func (s *SQLite) capabilities(ctx context.Context, id string) ([]device.Capability, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT capability FROM sense_device_capabilities WHERE device_id = ? ORDER BY capability`, id)
if err != nil {
return nil, fmt.Errorf("list device capabilities: %w", err)
}
defer rows.Close()
values := make([]device.Capability, 0)
for rows.Next() {
var value device.Capability
if err := rows.Scan(&value); err != nil {
return nil, fmt.Errorf("scan device capability: %w", err)
}
values = append(values, value)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate device capabilities: %w", err)
}
return values, nil
}
func sortedCapabilities(values []device.Capability) []device.Capability {
result := append([]device.Capability(nil), values...)
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
return result
}
func formatTime(value time.Time) string {
return value.UTC().Format(time.RFC3339Nano)
}
func parseTime(value string) (time.Time, error) {
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return time.Time{}, fmt.Errorf("parse stored timestamp: %w", err)
}
return parsed, nil
}
+206
View File
@@ -0,0 +1,206 @@
package store
import (
"context"
"errors"
"fmt"
"path/filepath"
"testing"
"time"
"yovision/sense/internal/device"
)
func TestDefaultVideoQuotaRejectsSeventeenthChannel(t *testing.T) {
t.Parallel()
store := openTestStore(t)
ctx := context.Background()
if err := store.EnsureSite(ctx, device.Site{TenantID: "tenant-a", ID: "site-a", Name: "Site A"}); err != nil {
t.Fatal(err)
}
for index := 1; index <= device.DefaultVideoChannels; index++ {
if err := store.CreateDevice(ctx, videoDevice(index, "tenant-a", "site-a")); err != nil {
t.Fatalf("create channel %d: %v", index, err)
}
}
err := store.CreateDevice(ctx, videoDevice(17, "tenant-a", "site-a"))
var quotaError *device.QuotaExceededError
if !errors.As(err, &quotaError) || quotaError.Limit != 16 {
t.Fatalf("expected 16-channel quota error, got %v", err)
}
}
func TestConfiguredMaximumAccepts128AndRejects129(t *testing.T) {
t.Parallel()
store := openTestStore(t)
ctx := context.Background()
if err := store.EnsureSite(ctx, device.Site{
TenantID: "tenant-b", ID: "site-b", Name: "Site B", MaxVideoChannels: 128,
}); err != nil {
t.Fatal(err)
}
for index := 1; index <= 128; index++ {
if err := store.CreateDevice(ctx, videoDevice(index, "tenant-b", "site-b")); err != nil {
t.Fatalf("create channel %d: %v", index, err)
}
}
err := store.CreateDevice(ctx, videoDevice(129, "tenant-b", "site-b"))
var quotaError *device.QuotaExceededError
if !errors.As(err, &quotaError) || quotaError.Limit != 128 {
t.Fatalf("expected 128-channel quota error, got %v", err)
}
}
func TestSiteRejectsCapacityAbove128(t *testing.T) {
t.Parallel()
store := openTestStore(t)
err := store.EnsureSite(context.Background(), device.Site{
TenantID: "tenant", ID: "site", Name: "Site", MaxVideoChannels: 129,
})
if err == nil {
t.Fatal("expected capacity 129 to be rejected")
}
}
func TestNonVideoDeviceDoesNotConsumeVideoQuota(t *testing.T) {
t.Parallel()
store := openTestStore(t)
ctx := context.Background()
if err := store.EnsureSite(ctx, device.Site{
TenantID: "tenant", ID: "site", Name: "Site", MaxVideoChannels: 1,
}); err != nil {
t.Fatal(err)
}
radar := device.Device{
ID: "radar-1", TenantID: "tenant", SiteID: "site", SerialNumber: "radar-1",
Name: "Radar", Modality: device.ModalityRadar,
Capabilities: []device.Capability{device.CapabilityTelemetry},
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
}
if err := store.CreateDevice(ctx, radar); err != nil {
t.Fatal(err)
}
if err := store.CreateDevice(ctx, videoDevice(1, "tenant", "site")); err != nil {
t.Fatalf("video channel should remain available: %v", err)
}
}
func TestEnablingSeventeenthVideoDeviceIsRejected(t *testing.T) {
t.Parallel()
store := openTestStore(t)
ctx := context.Background()
if err := store.EnsureSite(ctx, device.Site{TenantID: "tenant-c", ID: "site-c", Name: "Site C"}); err != nil {
t.Fatal(err)
}
for index := 1; index <= 17; index++ {
value := videoDevice(index, "tenant-c", "site-c")
if index == 17 {
value.DesiredState = device.DesiredDisabled
}
if err := store.CreateDevice(ctx, value); err != nil {
t.Fatalf("create device %d: %v", index, err)
}
}
err := store.SetDesiredState(ctx, "camera-017", device.DesiredEnabled)
var quotaError *device.QuotaExceededError
if !errors.As(err, &quotaError) || quotaError.Limit != 16 {
t.Fatalf("expected enable to enforce quota, got %v", err)
}
value, err := store.GetDevice(ctx, "camera-017")
if err != nil {
t.Fatal(err)
}
if value.DesiredState != device.DesiredDisabled {
t.Fatal("failed enable must leave the existing desired state unchanged")
}
}
func TestLowerQuotaDoesNotDisableExistingStreams(t *testing.T) {
t.Parallel()
store := openTestStore(t)
ctx := context.Background()
if err := store.EnsureSite(ctx, device.Site{
TenantID: "tenant-d", ID: "site-d", Name: "Site D", MaxVideoChannels: 2,
}); err != nil {
t.Fatal(err)
}
for index := 1; index <= 2; index++ {
if err := store.CreateDevice(ctx, videoDevice(index, "tenant-d", "site-d")); err != nil {
t.Fatal(err)
}
}
if err := store.EnsureSite(ctx, device.Site{
TenantID: "tenant-d", ID: "site-d", Name: "Site D", MaxVideoChannels: 1,
}); err != nil {
t.Fatal(err)
}
for index := 1; index <= 2; index++ {
value, err := store.GetDevice(ctx, fmt.Sprintf("camera-%03d", index))
if err != nil {
t.Fatal(err)
}
if value.DesiredState != device.DesiredEnabled {
t.Fatalf("existing channel %d was disabled", index)
}
}
}
func TestConvergenceSnapshotAndRuntimeReconcileRequest(t *testing.T) {
t.Parallel()
store := openTestStore(t)
ctx := context.Background()
if err := store.EnsureSite(ctx, device.Site{TenantID: "tenant", ID: "site", Name: "Site"}); err != nil {
t.Fatal(err)
}
if err := store.CreateDevice(ctx, videoDevice(1, "tenant", "site")); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
if err := store.MarkReconciled(ctx, "camera-001", 1, now); err != nil {
t.Fatal(err)
}
if err := store.UpdateActualState(ctx, "camera-001", device.ActualOnline, now); err != nil {
t.Fatal(err)
}
snapshot, err := store.ConvergenceSnapshot(ctx)
if err != nil {
t.Fatal(err)
}
if snapshot.Total != 1 || snapshot.Unconverged != 0 {
t.Fatalf("expected converged snapshot, got %+v", snapshot)
}
if err := store.RequestReconcile(ctx, "camera-001", now.Add(time.Second)); err != nil {
t.Fatal(err)
}
snapshot, err = store.ConvergenceSnapshot(ctx)
if err != nil {
t.Fatal(err)
}
if snapshot.Unconverged != 1 || snapshot.Devices[0].ObservedGeneration != 0 {
t.Fatalf("runtime loss must invalidate convergence: %+v", snapshot)
}
}
func openTestStore(t *testing.T) *SQLite {
t.Helper()
dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "sense.db"))
store, err := OpenSQLite(context.Background(), dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
return store
}
func videoDevice(index int, tenantID, siteID string) device.Device {
id := fmt.Sprintf("camera-%03d", index)
return device.Device{
ID: id, TenantID: tenantID, SiteID: siteID, AreaID: "area-default",
SerialNumber: id, Name: id,
Modality: device.ModalityVideo,
Capabilities: []device.Capability{device.CapabilityVideoCapture, device.CapabilitySpatialRule},
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
EndpointRef: "onvif://" + id, CredentialRef: "secret://" + id,
PathName: "sense/" + tenantID + "/" + siteID + "/" + id,
}
}
+441
View File
@@ -0,0 +1,441 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$CameraEnv,
[string]$RuntimeRoot = (Join-Path ([IO.Path]::GetTempPath()) 'yovision-t006'),
[ValidateRange(1, 1440)]
[int]$ObservationMinutes = 30,
[switch]$KeepSession
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
function Read-EnvFile([string]$Path) {
$result = @{}
Get-Content -LiteralPath $Path | ForEach-Object {
$line = $_.Trim()
if (-not $line -or $line.StartsWith('#') -or -not $line.Contains('=')) {
return
}
$parts = $line -split '=', 2
$value = $parts[1].Trim()
if ($value.Length -ge 2 -and (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'")))) {
$value = $value.Substring(1, $value.Length - 2)
}
$result[$parts[0].Trim().ToLowerInvariant()] = $value
}
return $result
}
function Require-Keys([hashtable]$Config, [string[]]$Keys) {
foreach ($key in $Keys) {
if (-not $Config.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($Config[$key])) {
throw "camera environment is missing required key: $key"
}
}
}
function Assert-PortFree([int]$Port) {
$client = [Net.Sockets.TcpClient]::new()
try {
$task = $client.ConnectAsync('127.0.0.1', $Port)
if ($task.Wait(250) -and $client.Connected) {
throw "required local port is already in use: $Port"
}
}
catch [AggregateException] {
return
}
catch [Net.Sockets.SocketException] {
return
}
finally {
$client.Dispose()
}
}
function Wait-Port([int]$Port, [int]$TimeoutSeconds = 30) {
$watch = [Diagnostics.Stopwatch]::StartNew()
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
$client = [Net.Sockets.TcpClient]::new()
try {
$task = $client.ConnectAsync('127.0.0.1', $Port)
if ($task.Wait(500) -and $client.Connected) {
return
}
}
catch {
}
finally {
$client.Dispose()
}
Start-Sleep -Milliseconds 250
}
throw "local port did not become ready: $Port"
}
function Start-ManagedProcess(
[string]$Name,
[string]$FilePath,
[string[]]$Arguments,
[hashtable]$Environment = @{}
) {
$start = [Diagnostics.ProcessStartInfo]::new()
$start.FileName = $FilePath
$start.WorkingDirectory = $session
$start.UseShellExecute = $false
$start.CreateNoWindow = $true
$start.RedirectStandardOutput = $true
$start.RedirectStandardError = $true
foreach ($argument in $Arguments) {
$start.ArgumentList.Add($argument)
}
foreach ($entry in $Environment.GetEnumerator()) {
$start.Environment[$entry.Key] = [string]$entry.Value
}
$process = [Diagnostics.Process]::new()
$process.StartInfo = $start
if (-not $process.Start()) {
throw "failed to start process: $Name"
}
return [pscustomobject]@{
Name = $Name
Process = $process
Stdout = $process.StandardOutput.ReadToEndAsync()
Stderr = $process.StandardError.ReadToEndAsync()
StartedAt = [DateTimeOffset]::UtcNow
}
}
function Stop-ManagedProcess($Managed) {
if ($null -eq $Managed -or $null -eq $Managed.Process) {
return
}
if (-not $Managed.Process.HasExited) {
# Every fixture is started as a leaf process. Killing only that exact
# process avoids Windows process-tree edge cases during fault tests.
$Managed.Process.Kill()
$Managed.Process.WaitForExit(10000) | Out-Null
}
}
function Assert-Alive($Managed) {
if ($null -eq $Managed -or $Managed.Process.HasExited) {
$code = if ($null -eq $Managed) { 'not-started' } else { $Managed.Process.ExitCode }
throw "required process exited: $($Managed.Name), code=$code"
}
}
function Invoke-LabStatus {
$lastDiagnostic = ''
foreach ($attempt in 1..5) {
$raw = @(& $labBinary status -db $databaseDSN 2>&1)
if ($LASTEXITCODE -eq 0) {
try {
return (($raw -join "`n") | ConvertFrom-Json)
}
catch {
$lastDiagnostic = 'invalid JSON response'
}
}
else {
$lastDiagnostic = (($raw -join "`n") -split "`r?`n" | Select-Object -Last 2) -join ' | '
}
Start-Sleep -Milliseconds 250
}
throw "sense-lab status failed after retries: $lastDiagnostic"
}
function Wait-Converged([int]$TimeoutSeconds = 180) {
$watch = [Diagnostics.Stopwatch]::StartNew()
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
try {
$snapshot = Invoke-LabStatus
if ([int]$snapshot.total -eq 5 -and [int]$snapshot.unconverged -eq 0) {
return [Math]::Round($watch.Elapsed.TotalSeconds, 1)
}
}
catch {
}
Start-Sleep -Seconds 1
}
throw 'five devices did not converge before timeout'
}
function Wait-DeviceState([string]$ID, [string]$State, [int]$TimeoutSeconds = 90) {
$watch = [Diagnostics.Stopwatch]::StartNew()
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
try {
$snapshot = Invoke-LabStatus
$match = @($snapshot.devices | Where-Object { $_.id -eq $ID })
if ($match.Count -eq 1 -and $match[0].actual_state -eq $State) {
return [Math]::Round($watch.Elapsed.TotalSeconds, 1)
}
}
catch {
}
Start-Sleep -Seconds 1
}
throw "device did not reach expected state: $ID/$State"
}
function Wait-SenseHealth($Managed, [int]$TimeoutSeconds = 30) {
$watch = [Diagnostics.Stopwatch]::StartNew()
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
if ($Managed.Process.HasExited) {
$output = @(
$Managed.Stdout.GetAwaiter().GetResult()
$Managed.Stderr.GetAwaiter().GetResult()
) -join "`n"
$summary = (($output -split "`r?`n") | Where-Object { $_ } | Select-Object -Last 3) -join ' | '
throw "Sense exited before health check, code=$($Managed.Process.ExitCode), output=$summary"
}
try {
$response = Invoke-RestMethod -Method Get -Uri 'http://127.0.0.1:18080/healthz' -TimeoutSec 2 -NoProxy
if ($response.status -eq 'ok') {
return
}
}
catch {
}
Start-Sleep -Milliseconds 500
}
$listeners = @(Get-NetTCPConnection -State Listen -OwningProcess $Managed.Process.Id -ErrorAction SilentlyContinue |
ForEach-Object { "$($_.LocalAddress):$($_.LocalPort)" })
throw "Sense health endpoint did not become ready; process listeners=$($listeners -join ',')"
}
function Start-Publisher([int]$Index) {
return Start-ManagedProcess "publisher-$Index" $ffmpeg @(
'-hide_banner', '-loglevel', 'warning', '-re',
'-f', 'lavfi', '-i', "testsrc2=size=640x360:rate=10",
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
'-pix_fmt', 'yuv420p', '-g', '10', '-an',
'-f', 'rtsp', '-rtsp_transport', 'tcp',
"rtsp://127.0.0.1:8555/synthetic-$Index"
)
}
function Start-Proxy {
return Start-ManagedProcess 'real-camera-network-proxy' $proxyBinary @(
'-listen', '127.0.0.1:10554', '-upstream', "$($camera.host):$($camera.rtspport)"
)
}
function Start-Sense {
$environment = @{
SENSE_HTTP_ADDR = '127.0.0.1:18080'
SENSE_DB_DSN = $databaseDSN
SENSE_MEDIAMTX_URL = 'http://127.0.0.1:9997'
SENSE_RECONCILE_INTERVAL = '1s'
SENSE_PROBE_INTERVAL = '1s'
SENSE_ONVIF_MODE = 'standard'
SENSE_ONVIF_RTSP_REWRITE_HOST = '127.0.0.1'
SENSE_ONVIF_RTSP_REWRITE_PORT = '10554'
SENSE_ONVIF_RTSP_STRIP_QUERY = 'true'
SENSE_CREDENTIAL_CAMERA_ONVIF_USERNAME = $camera.onvifuser
SENSE_CREDENTIAL_CAMERA_ONVIF_PASSWORD = $camera.onvifpwd
SENSE_CREDENTIAL_CAMERA_RTSP_USERNAME = $camera.username
SENSE_CREDENTIAL_CAMERA_RTSP_PASSWORD = $camera.password
}
return Start-ManagedProcess -Name 'sense-api' -FilePath $senseBinary -Arguments @() -Environment $environment
}
function Start-ProductionMediaMTX {
return Start-ManagedProcess 'mediamtx-production' $mediaMTX @($productionConfig)
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$cameraPath = (Resolve-Path -LiteralPath $CameraEnv).Path
$camera = Read-EnvFile $cameraPath
Require-Keys $camera @('host', 'username', 'password', 'rtspport', 'onvif', 'onvifuser', 'onvifpwd')
foreach ($port in 8554, 8555, 9997, 10554, 18080) {
Assert-PortFree $port
}
New-Item -ItemType Directory -Path $RuntimeRoot -Force | Out-Null
$mediaDirectory = Join-Path $RuntimeRoot 'mediamtx-v1.19.3'
$mediaMTX = Join-Path $mediaDirectory 'mediamtx.exe'
if (-not (Test-Path -LiteralPath $mediaMTX)) {
$zip = Join-Path $RuntimeRoot 'mediamtx_v1.19.3_windows_amd64.zip'
Invoke-WebRequest 'https://github.com/bluenviron/mediamtx/releases/download/v1.19.3/mediamtx_v1.19.3_windows_amd64.zip' -OutFile $zip
$actualHash = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne '5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe') {
throw 'MediaMTX checksum mismatch'
}
New-Item -ItemType Directory -Path $mediaDirectory -Force | Out-Null
Expand-Archive -LiteralPath $zip -DestinationPath $mediaDirectory -Force
}
$ffmpeg = (Get-Command ffmpeg -ErrorAction Stop).Source
$session = Join-Path $RuntimeRoot ('session-' + [Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $session | Out-Null
$senseBinary = Join-Path $session 'sense-api.exe'
$labBinary = Join-Path $session 'sense-lab.exe'
$proxyBinary = Join-Path $session 'rtsp-fault-proxy.exe'
$productionConfig = Join-Path $session 'mediamtx-production.yml'
$syntheticConfig = Join-Path $session 'mediamtx-synthetic.yml'
[IO.File]::Copy((Join-Path $repoRoot 'Sense\deploy\mediamtx.yml'), $productionConfig)
[IO.File]::Copy((Join-Path $repoRoot 'Sense\deploy\mediamtx-synthetic.yml'), $syntheticConfig)
& go -C (Join-Path $repoRoot 'Sense') build -o $senseBinary ./cmd/sense-api
if ($LASTEXITCODE -ne 0) { throw 'build sense-api failed' }
& go -C (Join-Path $repoRoot 'Sense') build -o $labBinary ./cmd/sense-lab
if ($LASTEXITCODE -ne 0) { throw 'build sense-lab failed' }
& go -C (Join-Path $repoRoot 'Sense') build -o $proxyBinary ./cmd/rtsp-fault-proxy
if ($LASTEXITCODE -ne 0) { throw 'build RTSP fault proxy failed' }
$databasePath = Join-Path $session 'sense.db'
$databaseDSN = 'file:' + $databasePath.Replace('\', '/')
$manifestPath = Join-Path $session 'manifest.json'
$onvifEndpoint = if ($camera.onvif -match '^https?://') {
$camera.onvif
} else {
"http://$($camera.host):$($camera.onvif)/onvif/device_service"
}
$devices = @(
[ordered]@{ id = 'camera-real'; tenant_id = 'lab'; site_id = 'site'; serial_number = 'e9ed6a555ae0'; name = 'Approved real camera'; capabilities = @('video_capture', 'audio_capture'); endpoint_ref = $onvifEndpoint; credential_ref = 'env://camera'; path_name = 'sense/lab/site/camera-real' }
)
foreach ($index in 1..4) {
$devices += [ordered]@{ id = "synthetic-$index"; tenant_id = 'lab'; site_id = 'site'; serial_number = "synthetic-$index"; name = "Synthetic source $index"; capabilities = @('video_capture'); endpoint_ref = "rtsp://127.0.0.1:8555/synthetic-$index"; credential_ref = ''; path_name = "sense/lab/site/synthetic-$index" }
}
[ordered]@{
site = [ordered]@{ tenant_id = 'lab'; id = 'site'; name = 'T-006 Lab'; max_video_channels = 16 }
devices = $devices
} | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
$managed = [Collections.Generic.List[object]]::new()
$publishers = @{}
$events = [Collections.Generic.List[object]]::new()
$observationSamples = 0
$maxUnconverged = 0
$success = $false
$stage = 'starting fixtures'
$failureMessage = $null
try {
$sourceMedia = Start-ManagedProcess 'mediamtx-synthetic' $mediaMTX @($syntheticConfig)
$managed.Add($sourceMedia)
Wait-Port 8555
$productionMedia = Start-ProductionMediaMTX
$managed.Add($productionMedia)
Wait-Port 9997
foreach ($index in 1..4) {
$publishers[$index] = Start-Publisher $index
$managed.Add($publishers[$index])
}
$stage = 'checking synthetic publishers'
Start-Sleep -Seconds 3
foreach ($publisher in $publishers.Values) { Assert-Alive $publisher }
$proxy = Start-Proxy
$managed.Add($proxy)
Wait-Port 10554
& $labBinary seed -db $databaseDSN -manifest $manifestPath | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'seed device ledger failed' }
$stage = 'initial convergence'
$sense = Start-Sense
$managed.Add($sense)
Wait-SenseHealth $sense
$initialSeconds = Wait-Converged
$events.Add([ordered]@{ event = 'initial_convergence'; seconds = $initialSeconds; unconverged = 0 })
$stage = 'real camera network recovery'
Stop-ManagedProcess $proxy
$offlineSeconds = Wait-DeviceState 'camera-real' 'offline'
$proxy = Start-Proxy
$managed.Add($proxy)
Wait-Port 10554
$recoverySeconds = Wait-Converged
$events.Add([ordered]@{ event = 'real_camera_network'; offline_detect_seconds = $offlineSeconds; recovery_seconds = $recoverySeconds; unconverged = 0 })
$stage = 'synthetic publisher recovery'
Stop-ManagedProcess $publishers[2]
$offlineSeconds = Wait-DeviceState 'synthetic-2' 'offline'
$publishers[2] = Start-Publisher 2
$managed.Add($publishers[2])
$recoverySeconds = Wait-Converged
$events.Add([ordered]@{ event = 'synthetic_publisher'; offline_detect_seconds = $offlineSeconds; recovery_seconds = $recoverySeconds; unconverged = 0 })
$stage = 'Sense restart recovery'
Stop-ManagedProcess $sense
$restartWatch = [Diagnostics.Stopwatch]::StartNew()
$sense = Start-Sense
$managed.Add($sense)
Wait-SenseHealth $sense
$recoverySeconds = Wait-Converged
$events.Add([ordered]@{ event = 'sense_restart'; process_ready_seconds = [Math]::Round($restartWatch.Elapsed.TotalSeconds, 1); recovery_seconds = $recoverySeconds; unconverged = 0 })
$stage = 'MediaMTX restart recovery'
Stop-ManagedProcess $productionMedia
Start-Sleep -Seconds 3
$productionMedia = Start-ProductionMediaMTX
$managed.Add($productionMedia)
Wait-Port 9997
$recoverySeconds = Wait-Converged 240
$events.Add([ordered]@{ event = 'mediamtx_restart'; recovery_seconds = $recoverySeconds; unconverged = 0 })
$stage = 'checking MediaMTX path count'
$configured = Invoke-RestMethod -Method Get -Uri 'http://127.0.0.1:9997/v3/config/paths/list' -TimeoutSec 5 -NoProxy
if ([int]$configured.itemCount -ne 5) {
throw "expected 5 MediaMTX paths, got $($configured.itemCount)"
}
$stage = 'stability observation'
$observation = [Diagnostics.Stopwatch]::StartNew()
$targetSeconds = $ObservationMinutes * 60
while ($observation.Elapsed.TotalSeconds -lt $targetSeconds) {
Assert-Alive $sourceMedia
Assert-Alive $productionMedia
Assert-Alive $sense
Assert-Alive $proxy
foreach ($publisher in $publishers.Values) { Assert-Alive $publisher }
$snapshot = Invoke-LabStatus
$observationSamples++
$maxUnconverged = [Math]::Max($maxUnconverged, [int]$snapshot.unconverged)
if ([int]$snapshot.total -ne 5 -or [int]$snapshot.unconverged -ne 0) {
throw "observation detected unconverged devices: $($snapshot.unconverged)"
}
Start-Sleep -Seconds 10
}
$observationSeconds = [Math]::Round($observation.Elapsed.TotalSeconds, 1)
$final = Invoke-LabStatus
$success = $true
[ordered]@{
success = $true
mediamtx_version = 'v1.19.3'
mediamtx_sha256 = '5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe'
source_count = 5
synthetic_publishers = 4
configured_paths = [int]$configured.itemCount
recovery = $events
observation_seconds = $observationSeconds
observation_samples = $observationSamples
maximum_unconverged = $maxUnconverged
final_unconverged = [int]$final.unconverged
} | ConvertTo-Json -Depth 8
}
catch {
$failureMessage = "T-006 stage '$stage' failed: $($_.Exception.Message)"
}
finally {
foreach ($item in @($managed)) {
Stop-ManagedProcess $item
}
if (-not $KeepSession) {
Get-ChildItem -LiteralPath $session -Recurse -Force -ErrorAction SilentlyContinue | ForEach-Object { $_.Attributes = 'Normal' }
if (Test-Path -LiteralPath $session) {
(Get-Item -LiteralPath $session -Force).Attributes = 'Directory'
Remove-Item -LiteralPath $session -Recurse -ErrorAction SilentlyContinue
}
}
if (-not $success) {
Write-Warning 'T-006 integration did not complete; no success evidence was emitted.'
}
}
if ($failureMessage) {
throw $failureMessage
}
+973
View File
@@ -0,0 +1,973 @@
[CmdletBinding()]
param(
[string]$PgRoot = 'D:\pgsql17',
[string]$RuntimeRoot = (Join-Path ([IO.Path]::GetTempPath()) 'yovision-t014'),
[ValidateRange(1, 1440)]
[int]$ObservationMinutes = 30,
[string]$OutputPath = '',
[switch]$PreflightOnly
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
Set-StrictMode -Version Latest
$sourceCount = 16
$faultIndexes = @(5, 6, 7, 8)
$sampleSeconds = 10
$mediaMTXVersion = 'v1.19.3'
$mediaMTXSHA256 = '5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe'
$mediaMTXExecutableSHA256 = '1cda85249312cb9463f9f94c5a712b9f160c9af3fd9490f0d4723911d7880e05'
$migrationNames = @(
'002_bell.sql',
'003_sense.sql',
'004_privileges.sql',
'005_area_policy.sql',
'006_device_operation_outbox.sql',
'007_privileges_area_audit.sql',
'008_control_api.sql',
'009_privileges_control_api.sql',
'010_reconcile_safety.sql',
'011_privileges_reconcile_safety.sql'
)
function ConvertTo-Base64Url([byte[]]$Bytes) {
return [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}
function New-RandomBytes([int]$Count) {
$bytes = [byte[]]::new($Count)
[Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
return $bytes
}
function New-SecretToken {
return ConvertTo-Base64Url (New-RandomBytes 32)
}
function Get-SHA256Hex([string]$Value) {
$bytes = [Text.Encoding]::UTF8.GetBytes($Value)
$digest = [Security.Cryptography.SHA256]::HashData($bytes)
return ([Convert]::ToHexString($digest)).ToLowerInvariant()
}
function Get-FreeTcpPort([Collections.Generic.HashSet[int]]$Reserved) {
while ($true) {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
$listener.Start()
$port = ([Net.IPEndPoint]$listener.LocalEndpoint).Port
$listener.Stop()
if ($Reserved.Add($port)) {
return $port
}
}
}
function Wait-Port([int]$Port, [int]$TimeoutSeconds = 30) {
$watch = [Diagnostics.Stopwatch]::StartNew()
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
$client = [Net.Sockets.TcpClient]::new()
try {
$task = $client.ConnectAsync('127.0.0.1', $Port)
if ($task.Wait(500) -and $client.Connected) {
return
}
}
catch {
}
finally {
$client.Dispose()
}
Start-Sleep -Milliseconds 250
}
throw "local process did not listen before timeout"
}
function Start-ManagedProcess(
[string]$Name,
[string]$FilePath,
[string[]]$Arguments,
[hashtable]$Environment = @{}
) {
$start = [Diagnostics.ProcessStartInfo]::new()
$start.FileName = $FilePath
$start.WorkingDirectory = $script:session
$start.UseShellExecute = $false
$start.CreateNoWindow = $true
$start.RedirectStandardOutput = $true
$start.RedirectStandardError = $true
foreach ($argument in $Arguments) {
$start.ArgumentList.Add($argument)
}
foreach ($entry in $Environment.GetEnumerator()) {
$start.Environment[$entry.Key] = [string]$entry.Value
}
$process = [Diagnostics.Process]::new()
$process.StartInfo = $start
if (-not $process.Start()) {
throw "failed to start required process: $Name"
}
return [pscustomobject]@{
Name = $Name
Process = $process
Stdout = $process.StandardOutput.ReadToEndAsync()
Stderr = $process.StandardError.ReadToEndAsync()
}
}
function Stop-ManagedProcess($Managed) {
if ($null -eq $Managed -or $null -eq $Managed.Process) {
return
}
if (-not $Managed.Process.HasExited) {
$Managed.Process.Kill()
$Managed.Process.WaitForExit(10000) | Out-Null
}
}
function Stop-AllManagedProcesses($Items) {
$processes = @($Items | Where-Object { $null -ne $_ -and $null -ne $_.Process })
foreach ($item in $processes) {
try {
if (-not $item.Process.HasExited) {
$item.Process.Kill()
}
}
catch {
if (-not $item.Process.HasExited) { throw }
}
}
$cleanupWatch = [Diagnostics.Stopwatch]::StartNew()
while ($cleanupWatch.Elapsed.TotalSeconds -lt 10 -and
@($processes | Where-Object { -not $_.Process.HasExited }).Count -gt 0) {
Start-Sleep -Milliseconds 100
}
$remaining = @($processes | Where-Object { -not $_.Process.HasExited })
if ($remaining.Count -gt 0) {
Write-Warning "managed processes did not exit within the shared cleanup deadline: $($remaining.Count)"
}
foreach ($item in $processes) {
if ($item.Process.HasExited) {
$item.Stdout.Wait(2000) | Out-Null
$item.Stderr.Wait(2000) | Out-Null
$item.Process.Dispose()
}
}
}
function Remove-SessionDirectory([string]$Path) {
Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue |
ForEach-Object { $_.Attributes = 'Normal' }
(Get-Item -LiteralPath $Path -Force).Attributes = 'Directory'
$deleteWatch = [Diagnostics.Stopwatch]::StartNew()
while (Test-Path -LiteralPath $Path) {
try {
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
}
catch {
if ($deleteWatch.Elapsed.TotalSeconds -ge 10) { throw }
Start-Sleep -Milliseconds 200
}
}
}
function Assert-Alive($Managed) {
if ($null -eq $Managed -or $Managed.Process.HasExited) {
$code = if ($null -eq $Managed) { 'not-started' } else { $Managed.Process.ExitCode }
throw "required process exited: $($Managed.Name), code=$code"
}
}
function Invoke-Checked {
param(
[Parameter(Mandatory)] [string]$FilePath,
[Parameter(ValueFromRemainingArguments)] [string[]]$Arguments
)
& $FilePath @Arguments 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "required command failed with exit code $LASTEXITCODE"
}
}
function Invoke-ControlRequest(
[string]$Method,
[string]$Path,
[int[]]$ExpectedStatus,
$Body = $null,
[hashtable]$ExtraHeaders = @{}
) {
$headers = @{
Authorization = "Bearer $script:controlToken"
Accept = 'application/json'
}
foreach ($entry in $ExtraHeaders.GetEnumerator()) {
$headers[$entry.Key] = $entry.Value
}
$parameters = @{
Method = $Method
Uri = "http://127.0.0.1:$script:senseHTTPPort$Path"
Headers = $headers
TimeoutSec = 15
NoProxy = $true
SkipHttpErrorCheck = $true
}
if ($null -ne $Body) {
$parameters.ContentType = 'application/json'
$parameters.Body = ($Body | ConvertTo-Json -Depth 10 -Compress)
}
$response = Invoke-WebRequest @parameters
$statusCode = [int]$response.StatusCode
$statusMatches = $false
foreach ($expected in $ExpectedStatus) {
if ([int]$expected -eq $statusCode) {
$statusMatches = $true
break
}
}
if (-not $statusMatches) {
$code = 'unknown'
try {
$code = ((Get-ResponseText $response) | ConvertFrom-Json).code
}
catch {
}
throw "Control API returned unexpected status $statusCode, expected=$($ExpectedStatus -join ','), code=$code"
}
return $response
}
function Get-ResponseText($Response) {
if ($Response.Content -is [byte[]]) {
return [Text.Encoding]::UTF8.GetString($Response.Content)
}
return [string]$Response.Content
}
function Convert-ResponseJSON($Response) {
return ((Get-ResponseText $Response) | ConvertFrom-Json)
}
function Get-ResponseETag($Response) {
$value = [string]$Response.Headers.ETag
if ([string]::IsNullOrWhiteSpace($value)) {
throw 'Control API response did not contain ETag'
}
return $value
}
function Get-DevicePage {
$response = Invoke-ControlRequest 'GET' '/api/v1/sites/site-t014/devices?limit=100' @(200)
return Convert-ResponseJSON $response
}
function Get-DeviceEntries([string[]]$DeviceIDs) {
$entries = [Collections.Generic.List[object]]::new()
foreach ($id in $DeviceIDs) {
$response = Invoke-ControlRequest 'GET' "/api/v1/sites/site-t014/devices/$id" @(200)
$entries.Add([pscustomobject]@{
DeviceID = $id
ETag = Get-ResponseETag $response
})
}
return @($entries)
}
function Invoke-BatchDesiredState(
[string]$DesiredState,
[object[]]$Entries,
[string]$KeySuffix
) {
$items = @($Entries | ForEach-Object {
[ordered]@{
device_id = $_.DeviceID
etag = $_.ETag
desired_state = $DesiredState
}
})
$watch = [Diagnostics.Stopwatch]::StartNew()
$response = Invoke-ControlRequest 'POST' '/api/v1/sites/site-t014/devices:batchDesiredState' @(202) ([ordered]@{
items = $items
reason = "T-014 laboratory $DesiredState baseline"
}) @{ 'Idempotency-Key' = "t014-batch-$KeySuffix-$script:runNonce" }
$operation = Convert-ResponseJSON $response
$results = @($operation.results)
if ($operation.status -ne 'succeeded' -or $results.Count -ne $sourceCount -or
@($results | Where-Object { $_.status -ne 'succeeded' }).Count -ne 0) {
$statusCounts = @($results | Group-Object status | Sort-Object Name |
ForEach-Object { "$($_.Name):$($_.Count)" }) -join ','
$errorCounts = @($results | Where-Object { $_.error_code } | Group-Object error_code | Sort-Object Name |
ForEach-Object { "$($_.Name):$($_.Count)" }) -join ','
throw "batch desired-state operation was not fully successful: operation=$($operation.status), items=$($results.Count), statuses=$statusCounts, errors=$errorCounts"
}
return [ordered]@{
status = [string]$operation.status
item_count = $results.Count
request_seconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3)
}
}
function Get-MediaInventory([int]$APIPort) {
$config = Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$APIPort/v3/config/paths/list?page=0&itemsPerPage=100" -TimeoutSec 5 -NoProxy
$runtime = Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$APIPort/v3/paths/list?page=0&itemsPerPage=100" -TimeoutSec 5 -NoProxy
$items = @($runtime.items)
[UInt64]$inboundBytes = 0
[UInt64]$frameErrors = 0
foreach ($item in $items) {
if ($null -ne $item.inboundBytes) {
$inboundBytes += [UInt64]$item.inboundBytes
}
if ($null -ne $item.inboundFramesInError) {
$frameErrors += [UInt64]$item.inboundFramesInError
}
}
return [pscustomobject]@{
Configured = [int]$config.itemCount
Runtime = [int]$runtime.itemCount
Online = @($items | Where-Object { $_.online -eq $true -and $_.available -eq $true }).Count
InboundBytes = $inboundBytes
FrameErrors = $frameErrors
}
}
function Get-SenseMetric([string]$Name, [string]$Labels) {
$response = Invoke-WebRequest -Method Get -Uri "http://127.0.0.1:$script:senseHTTPPort/metrics" -TimeoutSec 5 -NoProxy
$prefix = [Regex]::Escape($Name + $Labels)
$match = [Regex]::Match((Get-ResponseText $response), "(?m)^$prefix\s+([-+0-9.eE]+)$")
if (-not $match.Success) {
throw "required Sense metric was not found: $Name"
}
return [double]::Parse($match.Groups[1].Value, [Globalization.CultureInfo]::InvariantCulture)
}
function Wait-SenseHealth($Managed, [int]$TimeoutSeconds = 45) {
$watch = [Diagnostics.Stopwatch]::StartNew()
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
Assert-Alive $Managed
try {
$ready = Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$script:senseHTTPPort/readyz" -TimeoutSec 2 -NoProxy
if ($ready.status -eq 'ready') {
return
}
}
catch {
}
Start-Sleep -Milliseconds 500
}
throw 'Sense readiness endpoint did not become ready'
}
function Wait-SourcePublishers([int]$TimeoutSeconds = 90) {
$watch = [Diagnostics.Stopwatch]::StartNew()
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
foreach ($publisher in $script:publishers.Values) {
Assert-Alive $publisher
}
try {
$inventory = Get-MediaInventory $script:sourceAPIPort
if ($inventory.Runtime -eq $sourceCount -and $inventory.Online -eq $sourceCount) {
return [Math]::Round($watch.Elapsed.TotalSeconds, 1)
}
}
catch {
}
Start-Sleep -Seconds 1
}
throw 'independent source publishers did not all become online'
}
function Wait-Convergence([ValidateSet('enabled', 'disabled')] [string]$DesiredState, [int]$TimeoutSeconds = 300) {
$watch = [Diagnostics.Stopwatch]::StartNew()
$last = 'no observation'
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
try {
$page = Get-DevicePage
$devices = @($page.items)
$inventory = Get-MediaInventory $script:productionAPIPort
$totalMetric = Get-SenseMetric 'sense_reconcile_devices' '{state="total"}'
$unconvergedMetric = Get-SenseMetric 'sense_reconcile_devices' '{state="unconverged"}'
if ($DesiredState -eq 'enabled') {
$stateOK = $devices.Count -eq $sourceCount -and
@($devices | Where-Object {
$_.desired_state -ne 'enabled' -or $_.actual_state -ne 'online' -or -not $_.converged
}).Count -eq 0
$mediaOK = $inventory.Configured -eq $sourceCount -and
$inventory.Runtime -eq $sourceCount -and $inventory.Online -eq $sourceCount
$metricsOK = $totalMetric -eq $sourceCount -and $unconvergedMetric -eq 0
}
else {
$stateOK = $devices.Count -eq $sourceCount -and
@($devices | Where-Object {
$_.desired_state -ne 'disabled' -or $_.actual_state -ne 'offline' -or -not $_.converged
}).Count -eq 0
$mediaOK = $inventory.Configured -eq 0 -and $inventory.Runtime -eq 0
$metricsOK = $totalMetric -eq 0 -and $unconvergedMetric -eq 0
}
$last = "devices=$($devices.Count), configured=$($inventory.Configured), runtime=$($inventory.Runtime), online=$($inventory.Online), unconverged=$unconvergedMetric"
if ($stateOK -and $mediaOK -and $metricsOK) {
return [Math]::Round($watch.Elapsed.TotalSeconds, 1)
}
}
catch {
$last = $_.Exception.Message
}
Start-Sleep -Seconds 1
}
throw "devices did not reach $DesiredState convergence: $last"
}
function Wait-PartialFault([int]$TimeoutSeconds = 120) {
$watch = [Diagnostics.Stopwatch]::StartNew()
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
try {
$page = Get-DevicePage
$devices = @($page.items)
$online = @($devices | Where-Object { $_.actual_state -eq 'online' }).Count
$notOnline = $devices.Count - $online
if ($devices.Count -eq $sourceCount -and $online -eq ($sourceCount - $faultIndexes.Count) -and
$notOnline -eq $faultIndexes.Count) {
return [ordered]@{
detection_seconds = [Math]::Round($watch.Elapsed.TotalSeconds, 1)
affected = $notOnline
unaffected_online = $online
}
}
}
catch {
}
Start-Sleep -Seconds 1
}
throw 'four-source fault was not isolated to exactly four devices'
}
function Start-Publisher([int]$Index) {
$name = 'synthetic-{0:D2}' -f $Index
return Start-ManagedProcess "publisher-$Index" $script:ffmpeg @(
'-hide_banner', '-loglevel', 'warning', '-re', '-stream_loop', '-1',
'-i', $script:fixturePath, '-map', '0:v:0', '-c', 'copy', '-an',
'-f', 'rtsp', '-rtsp_transport', 'tcp',
"rtsp://127.0.0.1:$script:sourceRTSPPort/$name"
)
}
function Get-ProcessResourceSample($Managed, [double]$PreviousCPU, [double]$ElapsedSeconds) {
Assert-Alive $Managed
$Managed.Process.Refresh()
$totalCPU = $Managed.Process.TotalProcessorTime.TotalSeconds
$normalizedCPU = 0.0
if ($ElapsedSeconds -gt 0) {
$normalizedCPU = (($totalCPU - $PreviousCPU) / $ElapsedSeconds / [Environment]::ProcessorCount) * 100
}
return [pscustomobject]@{
TotalCPU = $totalCPU
CPUPercent = [Math]::Max(0, $normalizedCPU)
WorkingSetMiB = $Managed.Process.WorkingSet64 / 1MB
PrivateMiB = $Managed.Process.PrivateMemorySize64 / 1MB
Handles = $Managed.Process.HandleCount
}
}
function Get-Average([Collections.Generic.List[double]]$Values) {
if ($Values.Count -eq 0) { return 0.0 }
return ($Values | Measure-Object -Average).Average
}
function Get-Maximum([Collections.Generic.List[double]]$Values) {
if ($Values.Count -eq 0) { return 0.0 }
return ($Values | Measure-Object -Maximum).Maximum
}
function Get-PostgresScalar([string]$Query) {
$value = & $script:psql -X -A -t -v ON_ERROR_STOP=1 -d $script:adminDatabaseDSN -c $Query
if ($LASTEXITCODE -ne 0) {
throw 'PostgreSQL observation query failed'
}
return (($value | Out-String).Trim())
}
function Write-Result([string]$JSON) {
if (-not [string]::IsNullOrWhiteSpace($OutputPath)) {
$resolvedOutput = [IO.Path]::GetFullPath($OutputPath)
$repoPrefix = $script:repoRoot.TrimEnd('\') + '\'
if ($resolvedOutput.StartsWith($repoPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw 'capacity result output must stay outside the repository'
}
$parent = Split-Path -Parent $resolvedOutput
if (-not (Test-Path -LiteralPath $parent -PathType Container)) {
throw 'capacity result output directory does not exist'
}
[IO.File]::WriteAllText($resolvedOutput, $JSON + [Environment]::NewLine, [Text.UTF8Encoding]::new($false))
}
Write-Output $JSON
}
$script:repoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..')).Path
$pgRootResolved = (Resolve-Path -LiteralPath $PgRoot).Path
$pgBin = Join-Path $pgRootResolved 'bin'
$script:initdb = Join-Path $pgBin 'initdb.exe'
$script:pgCtl = Join-Path $pgBin 'pg_ctl.exe'
$script:psql = Join-Path $pgBin 'psql.exe'
$script:createdb = Join-Path $pgBin 'createdb.exe'
$postgres = Join-Path $pgBin 'postgres.exe'
foreach ($required in @($script:initdb, $script:pgCtl, $script:psql, $script:createdb, $postgres)) {
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
throw 'PostgreSQL executable is missing under the selected PgRoot'
}
}
$postgresVersion = (& $postgres --version 2>&1 | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $postgresVersion -notmatch 'PostgreSQL\) 17\.10$') {
throw 'T-014 requires the frozen PostgreSQL 17.10 binaries'
}
$script:ffmpeg = (Get-Command ffmpeg -ErrorAction Stop).Source
$ffmpegVersion = (& $script:ffmpeg -version 2>&1 | Select-Object -First 1)
if ($ffmpegVersion -notmatch '^ffmpeg version 8\.1\.2') {
throw 'T-014 requires the frozen FFmpeg 8.1.2 build'
}
$senseDirectory = Join-Path $script:repoRoot 'Sense'
$goVersion = (& go '-C' $senseDirectory 'version' 2>&1 | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $goVersion -notmatch '^go version go1\.26\.5 ') {
throw 'T-014 requires the frozen Sense Go 1.26.5 toolchain'
}
$systemTemp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\')
$runtimeResolved = [IO.Path]::GetFullPath($RuntimeRoot).TrimEnd('\')
$runtimePrefix = $systemTemp + '\'
if (-not $runtimeResolved.StartsWith($runtimePrefix, [StringComparison]::OrdinalIgnoreCase) -or
[IO.Path]::GetFileName($runtimeResolved) -notlike 'yovision-t014*') {
throw 'RuntimeRoot must be a yovision-t014 directory under the system temporary directory'
}
$mediaDirectory = Join-Path $runtimeResolved 'mediamtx-v1.19.3'
$mediaMTX = Join-Path $mediaDirectory 'mediamtx.exe'
if ($PreflightOnly) {
$memoryGiB = 0.0
try { $memoryGiB = (Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB } catch {}
Write-Result (([ordered]@{
success = $true
preflight_only = $true
source_count = $sourceCount
observation_minutes = $ObservationMinutes
formal_eligible = $ObservationMinutes -ge 30
mediamtx_cached = Test-Path -LiteralPath $mediaMTX -PathType Leaf
postgres_version = $postgresVersion
ffmpeg_version = [string]$ffmpegVersion
go_version = $goVersion
logical_processors = [Environment]::ProcessorCount
total_memory_gib = [Math]::Round($memoryGiB, 2)
} | ConvertTo-Json -Depth 5))
exit 0
}
New-Item -ItemType Directory -Path $runtimeResolved -Force | Out-Null
if (-not (Test-Path -LiteralPath $mediaMTX -PathType Leaf)) {
$zip = Join-Path $runtimeResolved 'mediamtx_v1.19.3_windows_amd64.zip'
Invoke-WebRequest 'https://github.com/bluenviron/mediamtx/releases/download/v1.19.3/mediamtx_v1.19.3_windows_amd64.zip' -OutFile $zip
$actualHash = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $mediaMTXSHA256) {
throw 'MediaMTX checksum mismatch'
}
New-Item -ItemType Directory -Path $mediaDirectory -Force | Out-Null
Expand-Archive -LiteralPath $zip -DestinationPath $mediaDirectory -Force
}
if ((Get-FileHash -LiteralPath $mediaMTX -Algorithm SHA256).Hash.ToLowerInvariant() -ne $mediaMTXExecutableSHA256) {
throw 'MediaMTX executable checksum mismatch'
}
$script:runNonce = [Guid]::NewGuid().ToString('N')
$script:session = Join-Path $runtimeResolved ("session-$script:runNonce")
New-Item -ItemType Directory -Path $script:session | Out-Null
$reservedPorts = [Collections.Generic.HashSet[int]]::new()
$script:sourceRTSPPort = Get-FreeTcpPort $reservedPorts
$script:sourceAPIPort = Get-FreeTcpPort $reservedPorts
$productionRTSPPort = Get-FreeTcpPort $reservedPorts
$script:productionAPIPort = Get-FreeTcpPort $reservedPorts
$productionMetricsPort = Get-FreeTcpPort $reservedPorts
$script:senseHTTPPort = Get-FreeTcpPort $reservedPorts
$postgresPort = Get-FreeTcpPort $reservedPorts
$sourceConfig = Join-Path $script:session 'mediamtx-source.yml'
$productionConfig = Join-Path $script:session 'mediamtx-production.yml'
$sourceLines = [Collections.Generic.List[string]]::new()
@(
'logLevel: warn',
"rtspAddress: 127.0.0.1:$script:sourceRTSPPort",
'rtspTransports: [tcp]',
'api: true',
"apiAddress: 127.0.0.1:$script:sourceAPIPort",
'metrics: false', 'rtmp: false', 'hls: false', 'webrtc: false', 'srt: false', 'moq: false',
'paths:'
) | ForEach-Object { $sourceLines.Add($_) }
foreach ($index in 1..$sourceCount) {
$sourceLines.Add((' synthetic-{0:D2}:' -f $index))
$sourceLines.Add(' source: publisher')
}
[IO.File]::WriteAllLines($sourceConfig, $sourceLines, [Text.UTF8Encoding]::new($false))
[IO.File]::WriteAllLines($productionConfig, @(
'logLevel: warn',
"rtspAddress: 127.0.0.1:$productionRTSPPort",
'rtspTransports: [tcp]',
'api: true',
"apiAddress: 127.0.0.1:$script:productionAPIPort",
'metrics: true',
"metricsAddress: 127.0.0.1:$productionMetricsPort",
'rtmp: false', 'hls: false', 'webrtc: false', 'srt: false', 'moq: false',
'paths: {}'
), [Text.UTF8Encoding]::new($false))
$script:fixturePath = Join-Path $script:session 'fixture.mp4'
$senseBinary = Join-Path $script:session 'sense-api.exe'
$authPath = Join-Path $script:session 'control-auth.json'
$cursorPath = Join-Path $script:session 'cursor.key'
$pgData = Join-Path $script:session 'pgdata'
$pgLog = Join-Path $script:session 'postgres.log'
$databaseName = 'yovision_t014'
$adminRootDSN = "postgres://postgres@127.0.0.1:$postgresPort/postgres?sslmode=disable"
$script:adminDatabaseDSN = "postgres://postgres@127.0.0.1:$postgresPort/${databaseName}?sslmode=disable"
$senseDSN = "postgres://yovision_t014_sense@127.0.0.1:$postgresPort/${databaseName}?sslmode=disable"
$existing5432 = @(
Get-NetTCPConnection -State Listen -LocalPort 5432 -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty OwningProcess -Unique | Sort-Object
)
$managed = [Collections.Generic.List[object]]::new()
$script:publishers = @{}
$postgresStarted = $false
$postgresStopped = $false
$success = $false
$stage = 'preparing fixtures'
$failure = $null
$resultJSON = $null
try {
Invoke-Checked $script:ffmpeg '-hide_banner' '-loglevel' 'error' '-f' 'lavfi' '-i' 'testsrc2=size=640x360:rate=10' '-t' '30' '-c:v' 'libx264' '-preset' 'ultrafast' '-tune' 'zerolatency' '-pix_fmt' 'yuv420p' '-g' '10' '-an' '-movflags' '+faststart' '-y' $script:fixturePath
Invoke-Checked 'go' '-C' $senseDirectory 'build' '-o' $senseBinary './cmd/sense-api'
$stage = 'starting isolated PostgreSQL'
New-Item -ItemType Directory -Path $pgData | Out-Null
Invoke-Checked $script:initdb '-D' $pgData '-U' 'postgres' '-A' 'trust' '--encoding=UTF8' '--no-locale' '--no-sync'
$serverOptions = "-h 127.0.0.1 -p $postgresPort -c listen_addresses=127.0.0.1"
# Do not pipe pg_ctl start output: postgres can inherit the pipeline handle and
# keep PowerShell waiting until the server exits.
& $script:pgCtl '-D' $pgData '-l' $pgLog '-o' $serverOptions '-w' 'start'
if ($LASTEXITCODE -ne 0) {
throw "required command failed with exit code $LASTEXITCODE"
}
$postgresStarted = $true
Invoke-Checked $script:psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminRootDSN '-f' (Join-Path $script:repoRoot 'deploy\postgres\001_roles.sql')
Invoke-Checked $script:createdb '-h' '127.0.0.1' '-p' ([string]$postgresPort) '-U' 'postgres' $databaseName
foreach ($pass in 1..2) {
foreach ($name in $migrationNames) {
Invoke-Checked $script:psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $script:adminDatabaseDSN '-f' (Join-Path $script:repoRoot "deploy\postgres\$name")
}
}
Invoke-Checked $script:psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminRootDSN '-c' 'CREATE ROLE yovision_t014_sense LOGIN IN ROLE sense_app'
Invoke-Checked $script:psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $script:adminDatabaseDSN '-c' "INSERT INTO bell.sites(tenant_id,id,name,max_video_channels) VALUES ('tenant-t014','site-t014','T-014 Lab',16); INSERT INTO bell.areas(tenant_id,site_id,id,name,capture_policy) VALUES ('tenant-t014','site-t014','area-t014','T-014 Area','video_allowed');"
$stage = 'starting independent publishers'
$sourceMedia = Start-ManagedProcess 'mediamtx-source' $mediaMTX @($sourceConfig)
$managed.Add($sourceMedia)
Wait-Port $script:sourceAPIPort
foreach ($index in 1..$sourceCount) {
$script:publishers[$index] = Start-Publisher $index
$managed.Add($script:publishers[$index])
}
$publisherReadySeconds = Wait-SourcePublishers
$productionMedia = Start-ManagedProcess 'mediamtx-production' $mediaMTX @($productionConfig)
$managed.Add($productionMedia)
Wait-Port $script:productionAPIPort
$stage = 'starting Sense Control API'
$script:controlToken = New-SecretToken
$tokenDigest = Get-SHA256Hex $script:controlToken
$cursorKey = ConvertTo-Base64Url (New-RandomBytes 32)
$localCredential = New-SecretToken
[IO.File]::WriteAllText($authPath, (([ordered]@{
version = 1
principals = @([ordered]@{
token_sha256 = $tokenDigest
subject_id = 't014-runner'
actor_type = 'service'
tenant_id = 'tenant-t014'
site_ids = @('site-t014')
permissions = @('sense.devices.read', 'sense.devices.write')
})
} | ConvertTo-Json -Depth 6) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false))
[IO.File]::WriteAllText($cursorPath, $cursorKey + [Environment]::NewLine, [Text.UTF8Encoding]::new($false))
$senseEnvironment = @{
SENSE_HTTP_ADDR = "127.0.0.1:$script:senseHTTPPort"
SENSE_DB_DRIVER = 'postgres'
SENSE_DB_DSN = $senseDSN
SENSE_MEDIAMTX_URL = "http://127.0.0.1:$script:productionAPIPort"
SENSE_RECONCILE_INTERVAL = '1s'
SENSE_RECONCILE_LEASE_DURATION = '30s'
SENSE_RECONCILE_OPERATION_TIMEOUT = '20s'
SENSE_PROBE_INTERVAL = '1s'
SENSE_INSTANCE_ID = 't014-lab'
SENSE_METRICS_ENABLED = 'true'
SENSE_ORPHAN_SCAN_ENABLED = 'false'
SENSE_ONVIF_MODE = 'disabled'
SENSE_CONTROL_API_ENABLED = 'true'
SENSE_CONTROL_AUTH_FILE = $authPath
SENSE_CONTROL_CURSOR_KEY_FILE = $cursorPath
SENSE_CREDENTIAL_SYNTHETIC_ONVIF_USERNAME = 't014-local'
SENSE_CREDENTIAL_SYNTHETIC_ONVIF_PASSWORD = $localCredential
SENSE_CREDENTIAL_SYNTHETIC_RTSP_USERNAME = 't014-local'
SENSE_CREDENTIAL_SYNTHETIC_RTSP_PASSWORD = $localCredential
}
$sense = Start-ManagedProcess 'sense-api' $senseBinary @() $senseEnvironment
$managed.Add($sense)
Wait-SenseHealth $sense
$stage = 'creating 16 disabled devices'
$deviceIDs = [Collections.Generic.List[string]]::new()
foreach ($index in 1..$sourceCount) {
$name = 'synthetic-{0:D2}' -f $index
$response = Invoke-ControlRequest 'POST' '/api/v1/sites/site-t014/devices' @(201) ([ordered]@{
serial_number = "t014-$name"
name = "T-014 source $index"
modality = 'video'
capabilities = @('video_capture')
area_id = 'area-t014'
endpoint_ref = "rtsp://127.0.0.1:$script:sourceRTSPPort/$name"
credential_ref = 'env://synthetic'
desired_state = 'disabled'
}) @{ 'Idempotency-Key' = "t014-create-$index-$script:runNonce" }
$created = Convert-ResponseJSON $response
$deviceIDs.Add([string]$created.id)
}
$stage = 'batch enabling 16 devices'
$firstEnableEntries = Get-DeviceEntries @($deviceIDs)
$firstEnableBatch = Invoke-BatchDesiredState 'enabled' $firstEnableEntries 'enable-1'
$firstEnableSeconds = Wait-Convergence 'enabled'
$firstEnabledInventory = Get-MediaInventory $script:productionAPIPort
$stage = 'rejecting seventeenth enabled device'
$overflowResponse = Invoke-ControlRequest 'POST' '/api/v1/sites/site-t014/devices' @(409) ([ordered]@{
serial_number = 't014-overflow-17'
name = 'T-014 overflow source'
modality = 'video'
capabilities = @('video_capture')
area_id = 'area-t014'
endpoint_ref = "rtsp://127.0.0.1:$script:sourceRTSPPort/synthetic-01"
credential_ref = 'env://synthetic'
desired_state = 'enabled'
}) @{ 'Idempotency-Key' = "t014-overflow-17-$script:runNonce" }
$overflowProblem = Convert-ResponseJSON $overflowResponse
$overflowCodeProperty = $overflowProblem.PSObject.Properties['code']
if ($null -eq $overflowCodeProperty) {
$propertyNames = @($overflowProblem.PSObject.Properties.Name) -join ','
throw "quota response did not contain a stable code field; properties=$propertyNames"
}
$overflowCode = [string]$overflowCodeProperty.Value
$pageAfterOverflow = Get-DevicePage
if ($overflowCode -ne 'quota_exceeded' -or @($pageAfterOverflow.items).Count -ne $sourceCount) {
throw 'the seventeenth enabled device did not fail closed at the site quota'
}
$stage = 'batch disabling 16 devices'
$disableEntries = Get-DeviceEntries @($deviceIDs)
$disableBatch = Invoke-BatchDesiredState 'disabled' $disableEntries 'disable'
$disableSeconds = Wait-Convergence 'disabled'
$disabledInventory = Get-MediaInventory $script:productionAPIPort
$stage = 'batch re-enabling 16 devices'
$secondEnableEntries = Get-DeviceEntries @($deviceIDs)
$secondEnableBatch = Invoke-BatchDesiredState 'enabled' $secondEnableEntries 'enable-2'
$secondEnableSeconds = Wait-Convergence 'enabled'
$secondEnabledInventory = Get-MediaInventory $script:productionAPIPort
$stage = 'injecting four-source fault'
foreach ($index in $faultIndexes) {
Stop-ManagedProcess $script:publishers[$index]
}
$fault = Wait-PartialFault
foreach ($index in $faultIndexes) {
$script:publishers[$index] = Start-Publisher $index
$managed.Add($script:publishers[$index])
}
$faultRecoverySeconds = Wait-Convergence 'enabled'
$fault.recovery_seconds = $faultRecoverySeconds
$fault.final_unconverged = 0
$stage = 'observing stable 16-stream capacity'
$observation = [Diagnostics.Stopwatch]::StartNew()
$targetSeconds = $ObservationMinutes * 60
$senseCPU = [Collections.Generic.List[double]]::new()
$senseWorking = [Collections.Generic.List[double]]::new()
$sensePrivate = [Collections.Generic.List[double]]::new()
$senseHandles = [Collections.Generic.List[double]]::new()
$mediaCPU = [Collections.Generic.List[double]]::new()
$mediaWorking = [Collections.Generic.List[double]]::new()
$mediaPrivate = [Collections.Generic.List[double]]::new()
$mediaHandles = [Collections.Generic.List[double]]::new()
$databaseConnections = [Collections.Generic.List[double]]::new()
$previousSenseCPU = $sense.Process.TotalProcessorTime.TotalSeconds
$previousMediaCPU = $productionMedia.Process.TotalProcessorTime.TotalSeconds
$observationSenseCPUStart = $previousSenseCPU
$observationMediaCPUStart = $previousMediaCPU
$previousSampleAt = 0.0
$initialInventory = Get-MediaInventory $script:productionAPIPort
$samples = 0
$requiredSamples = [int][Math]::Ceiling($targetSeconds / $sampleSeconds)
$maximumUnconverged = 0
while ($samples -lt $requiredSamples) {
$nextSampleAt = ($samples + 1) * $sampleSeconds
$delayMilliseconds = [int][Math]::Ceiling(($nextSampleAt - $observation.Elapsed.TotalSeconds) * 1000)
if ($delayMilliseconds -gt 0) {
Start-Sleep -Milliseconds $delayMilliseconds
}
Assert-Alive $sourceMedia
Assert-Alive $productionMedia
Assert-Alive $sense
foreach ($publisher in $script:publishers.Values) { Assert-Alive $publisher }
$elapsed = $observation.Elapsed.TotalSeconds
$delta = $elapsed - $previousSampleAt
$senseSample = Get-ProcessResourceSample $sense $previousSenseCPU $delta
$mediaSample = Get-ProcessResourceSample $productionMedia $previousMediaCPU $delta
$previousSenseCPU = $senseSample.TotalCPU
$previousMediaCPU = $mediaSample.TotalCPU
$previousSampleAt = $elapsed
$senseCPU.Add($senseSample.CPUPercent)
$senseWorking.Add($senseSample.WorkingSetMiB)
$sensePrivate.Add($senseSample.PrivateMiB)
$senseHandles.Add($senseSample.Handles)
$mediaCPU.Add($mediaSample.CPUPercent)
$mediaWorking.Add($mediaSample.WorkingSetMiB)
$mediaPrivate.Add($mediaSample.PrivateMiB)
$mediaHandles.Add($mediaSample.Handles)
$databaseConnections.Add([double](Get-PostgresScalar "SELECT count(*) FROM pg_stat_activity WHERE datname = '$databaseName'"))
$page = Get-DevicePage
$inventory = Get-MediaInventory $script:productionAPIPort
$unconverged = [int](Get-SenseMetric 'sense_reconcile_devices' '{state="unconverged"}')
$maximumUnconverged = [Math]::Max($maximumUnconverged, $unconverged)
if (@($page.items).Count -ne $sourceCount -or
@($page.items | Where-Object { $_.desired_state -ne 'enabled' -or $_.actual_state -ne 'online' -or -not $_.converged }).Count -ne 0 -or
$inventory.Configured -ne $sourceCount -or $inventory.Runtime -ne $sourceCount -or
$inventory.Online -ne $sourceCount -or $unconverged -ne 0) {
throw 'stability observation detected a non-converged stream'
}
$samples++
}
$observationSeconds = [Math]::Round($observation.Elapsed.TotalSeconds, 1)
$finalInventory = Get-MediaInventory $script:productionAPIPort
$sense.Process.Refresh()
$productionMedia.Process.Refresh()
$senseCPUTotal = $sense.Process.TotalProcessorTime.TotalSeconds - $observationSenseCPUStart
$mediaCPUTotal = $productionMedia.Process.TotalProcessorTime.TotalSeconds - $observationMediaCPUStart
$inboundDelta = [UInt64]($finalInventory.InboundBytes - $initialInventory.InboundBytes)
$frameErrorDelta = [UInt64]($finalInventory.FrameErrors - $initialInventory.FrameErrors)
$inboundMbps = if ($observationSeconds -gt 0) { ($inboundDelta * 8.0) / $observationSeconds / 1000000.0 } else { 0.0 }
$databaseSizeBytes = [Int64](Get-PostgresScalar "SELECT pg_database_size('$databaseName')")
$finalUnconverged = [int](Get-SenseMetric 'sense_reconcile_devices' '{state="unconverged"}')
if ($observationSeconds -lt $targetSeconds -or $finalUnconverged -ne 0 -or
$maximumUnconverged -ne 0 -or $samples -lt $requiredSamples) {
throw 'formal observation did not satisfy sampling and convergence requirements'
}
$memoryGiB = 0.0
try { $memoryGiB = (Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB } catch {}
$gitCommit = (& git -C $script:repoRoot rev-parse HEAD | Out-String).Trim()
$success = $true
$resultJSON = [ordered]@{
success = $true
formal_eligible = $ObservationMinutes -ge 30
scope = 'laboratory_software_baseline'
source_count = $sourceCount
independent_publishers = $sourceCount
fixture = [ordered]@{ width = 640; height = 360; fps = 10; codec = 'H.264'; audio = $false; publish_mode = 'preencoded_copy' }
versions = [ordered]@{
repository_commit = $gitCommit
sense_sha256 = (Get-FileHash -LiteralPath $senseBinary -Algorithm SHA256).Hash.ToLowerInvariant()
mediamtx = $mediaMTXVersion
mediamtx_package_sha256 = $mediaMTXSHA256
mediamtx_executable_sha256 = $mediaMTXExecutableSHA256
postgres = $postgresVersion
ffmpeg = [string]$ffmpegVersion
go = $goVersion
}
host = [ordered]@{ logical_processors = [Environment]::ProcessorCount; total_memory_gib = [Math]::Round($memoryGiB, 2) }
quota = [ordered]@{ configured = 16; accepted_devices = 16; seventeenth_rejected = $true; error_code = 'quota_exceeded' }
publishers_ready_seconds = $publisherReadySeconds
batch = [ordered]@{
enable_first = $firstEnableBatch
disable = $disableBatch
enable_second = $secondEnableBatch
}
convergence_seconds = [ordered]@{
enable_first = $firstEnableSeconds
disable = $disableSeconds
enable_second = $secondEnableSeconds
}
configured_paths = [ordered]@{
after_enable_first = $firstEnabledInventory.Configured
after_disable = $disabledInventory.Configured
after_enable_second = $secondEnabledInventory.Configured
}
fault_recovery = $fault
observation = [ordered]@{
requested_minutes = $ObservationMinutes
seconds = $observationSeconds
sample_interval_seconds = $sampleSeconds
samples = $samples
maximum_unconverged = $maximumUnconverged
final_unconverged = $finalUnconverged
final_online_paths = $finalInventory.Online
aggregate_inbound_mbps = [Math]::Round($inboundMbps, 3)
inbound_frame_errors = $frameErrorDelta
}
resources = [ordered]@{
sense = [ordered]@{
cpu_average_percent = [Math]::Round((Get-Average $senseCPU), 3)
cpu_peak_percent = [Math]::Round((Get-Maximum $senseCPU), 3)
cpu_total_seconds = [Math]::Round($senseCPUTotal, 3)
working_set_peak_mib = [Math]::Round((Get-Maximum $senseWorking), 2)
private_peak_mib = [Math]::Round((Get-Maximum $sensePrivate), 2)
handles_peak = [int](Get-Maximum $senseHandles)
}
mediamtx = [ordered]@{
cpu_average_percent = [Math]::Round((Get-Average $mediaCPU), 3)
cpu_peak_percent = [Math]::Round((Get-Maximum $mediaCPU), 3)
cpu_total_seconds = [Math]::Round($mediaCPUTotal, 3)
working_set_peak_mib = [Math]::Round((Get-Maximum $mediaWorking), 2)
private_peak_mib = [Math]::Round((Get-Maximum $mediaPrivate), 2)
handles_peak = [int](Get-Maximum $mediaHandles)
}
postgres_peak_connections = [int](Get-Maximum $databaseConnections)
postgres_database_mib = [Math]::Round($databaseSizeBytes / 1MB, 2)
}
limitations = @('synthetic_sources', 'no_customer_network', 'no_recording', 'no_downstream_readers', 'no_ai_or_gpu', 'not_a_production_sla')
} | ConvertTo-Json -Depth 12
}
catch {
$failure = "T-014 stage '$stage' failed: $($_.Exception.Message)"
}
finally {
Stop-AllManagedProcesses @($managed)
if ($postgresStarted) {
& $script:pgCtl '-D' $pgData '-m' 'fast' '-w' 'stop' | Out-Null
$postgresStopped = $LASTEXITCODE -eq 0
}
if (-not $postgresStarted -or $postgresStopped) {
$resolvedSession = [IO.Path]::GetFullPath($script:session)
$expectedPrefix = $runtimeResolved.TrimEnd('\') + '\'
if (-not $resolvedSession.StartsWith($expectedPrefix, [StringComparison]::OrdinalIgnoreCase) -or
[IO.Path]::GetFileName($resolvedSession) -notlike 'session-*') {
throw 'refusing to clean an unexpected T-014 session path'
}
if (Test-Path -LiteralPath $resolvedSession) {
Remove-SessionDirectory $resolvedSession
}
}
else {
Write-Warning 'Temporary PostgreSQL did not stop; the session directory was retained for manual recovery'
}
$after5432 = @(
Get-NetTCPConnection -State Listen -LocalPort 5432 -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty OwningProcess -Unique | Sort-Object
)
if (($existing5432 -join ',') -ne ($after5432 -join ',')) {
throw 'the existing PostgreSQL listener on port 5432 changed during T-014'
}
}
if (-not $success) {
throw $failure
}
Write-Result $resultJSON
+24
View File
@@ -0,0 +1,24 @@
-- T-009 cluster roles. Runtime login roles are deployment-specific members of
-- these NOLOGIN roles; passwords and login principals never belong in Git.
DO $roles$
DECLARE
role_record pg_roles%ROWTYPE;
BEGIN
SELECT * INTO role_record FROM pg_roles WHERE rolname = 'bell_app';
IF NOT FOUND THEN
CREATE ROLE bell_app NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;
ELSIF role_record.rolsuper OR role_record.rolcreatedb OR role_record.rolcreaterole
OR role_record.rolreplication OR role_record.rolcanlogin THEN
RAISE EXCEPTION 'existing bell_app role has unsafe attributes';
END IF;
SELECT * INTO role_record FROM pg_roles WHERE rolname = 'sense_app';
IF NOT FOUND THEN
CREATE ROLE sense_app NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;
ELSIF role_record.rolsuper OR role_record.rolcreatedb OR role_record.rolcreaterole
OR role_record.rolreplication OR role_record.rolcanlogin THEN
RAISE EXCEPTION 'existing sense_app role has unsafe attributes';
END IF;
END
$roles$;
+73
View File
@@ -0,0 +1,73 @@
-- Bell owns Tenant/Site/quota truth. Run as the cluster administrator or a
-- migration role that can SET ROLE to bell_app.
CREATE SCHEMA IF NOT EXISTS bell AUTHORIZATION bell_app;
ALTER SCHEMA bell OWNER TO bell_app;
CREATE TABLE IF NOT EXISTS bell.schema_migrations (
version bigint PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
ALTER TABLE bell.schema_migrations OWNER TO bell_app;
CREATE TABLE IF NOT EXISTS bell.sites (
tenant_id text NOT NULL,
id text NOT NULL,
name text NOT NULL,
max_video_channels smallint NOT NULL DEFAULT 16,
version bigint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
deleted_at timestamptz,
PRIMARY KEY (tenant_id, id),
CONSTRAINT bell_sites_video_quota_range
CHECK (max_video_channels BETWEEN 1 AND 128),
CONSTRAINT bell_sites_version_positive CHECK (version >= 1),
CONSTRAINT bell_sites_identity_not_blank
CHECK (btrim(tenant_id) <> '' AND btrim(id) <> '' AND btrim(name) <> '')
);
ALTER TABLE bell.sites OWNER TO bell_app;
CREATE OR REPLACE FUNCTION bell.bump_site_version()
RETURNS trigger
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = pg_catalog, bell
AS $function$
BEGIN
IF NEW.tenant_id IS DISTINCT FROM OLD.tenant_id OR NEW.id IS DISTINCT FROM OLD.id THEN
RAISE EXCEPTION 'Bell site identity is immutable';
END IF;
NEW.version := OLD.version + 1;
NEW.updated_at := clock_timestamp();
RETURN NEW;
END
$function$;
ALTER FUNCTION bell.bump_site_version() OWNER TO bell_app;
DROP TRIGGER IF EXISTS bell_sites_bump_version ON bell.sites;
CREATE TRIGGER bell_sites_bump_version
BEFORE UPDATE ON bell.sites
FOR EACH ROW EXECUTE FUNCTION bell.bump_site_version();
CREATE OR REPLACE VIEW bell.site_quota_v1 (
tenant_id,
site_id,
max_video_channels,
source_version,
source_updated_at
) AS
SELECT
site.tenant_id,
site.id,
site.max_video_channels,
site.version,
site.updated_at
FROM bell.sites AS site
WHERE site.deleted_at IS NULL;
ALTER VIEW bell.site_quota_v1 OWNER TO bell_app;
COMMENT ON VIEW bell.site_quota_v1 IS
'v1 read-only site video quota projection owned by Bell and consumed by Sense';
INSERT INTO bell.schema_migrations(version) VALUES (1)
ON CONFLICT (version) DO NOTHING;
+86
View File
@@ -0,0 +1,86 @@
-- Sense owns device desired state and reconciliation progress. It deliberately
-- has no writable Site or quota truth table.
CREATE SCHEMA IF NOT EXISTS sense AUTHORIZATION sense_app;
ALTER SCHEMA sense OWNER TO sense_app;
CREATE TABLE IF NOT EXISTS sense.schema_migrations (
version bigint PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
ALTER TABLE sense.schema_migrations OWNER TO sense_app;
CREATE TABLE IF NOT EXISTS sense.devices (
id text PRIMARY KEY,
tenant_id text NOT NULL,
site_id text NOT NULL,
serial_number text NOT NULL,
name text NOT NULL,
modality text NOT NULL,
desired_state text NOT NULL,
actual_state text NOT NULL,
endpoint_ref text NOT NULL DEFAULT '',
credential_ref text NOT NULL DEFAULT '',
path_name text NOT NULL DEFAULT '',
generation bigint NOT NULL DEFAULT 1,
quota_source_version bigint,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CONSTRAINT sense_devices_identity_not_blank CHECK (
btrim(id) <> '' AND btrim(tenant_id) <> '' AND btrim(site_id) <> ''
AND btrim(serial_number) <> '' AND btrim(name) <> ''
),
CONSTRAINT sense_devices_modality CHECK (
modality IN ('video', 'radar', 'contact', 'button', 'wearable', 'other')
),
CONSTRAINT sense_devices_desired_state CHECK (desired_state IN ('disabled', 'enabled')),
CONSTRAINT sense_devices_actual_state CHECK (actual_state IN ('pending', 'online', 'offline', 'failed')),
CONSTRAINT sense_devices_generation_positive CHECK (generation >= 1),
CONSTRAINT sense_devices_quota_version_positive CHECK (
quota_source_version IS NULL OR quota_source_version >= 1
),
UNIQUE (tenant_id, site_id, serial_number)
);
ALTER TABLE sense.devices OWNER TO sense_app;
CREATE TABLE IF NOT EXISTS sense.device_capabilities (
device_id text NOT NULL REFERENCES sense.devices(id) ON DELETE CASCADE,
capability text NOT NULL,
PRIMARY KEY (device_id, capability),
CONSTRAINT sense_device_capability_known CHECK (
capability IN ('video_capture', 'audio_capture', 'spatial_rule', 'telemetry')
)
);
ALTER TABLE sense.device_capabilities OWNER TO sense_app;
CREATE TABLE IF NOT EXISTS sense.reconcile_state (
device_id text PRIMARY KEY REFERENCES sense.devices(id) ON DELETE CASCADE,
failure_count integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz,
last_error_code text,
observed_generation bigint NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL,
CONSTRAINT sense_reconcile_failure_nonnegative CHECK (failure_count >= 0),
CONSTRAINT sense_reconcile_generation_nonnegative CHECK (observed_generation >= 0)
);
ALTER TABLE sense.reconcile_state OWNER TO sense_app;
CREATE TABLE IF NOT EXISTS sense.site_quota_projection_state (
tenant_id text NOT NULL,
site_id text NOT NULL,
source_version bigint NOT NULL,
synced_at timestamptz NOT NULL,
PRIMARY KEY (tenant_id, site_id),
CONSTRAINT sense_quota_projection_version_positive CHECK (source_version >= 1)
);
ALTER TABLE sense.site_quota_projection_state OWNER TO sense_app;
CREATE INDEX IF NOT EXISTS sense_devices_site_state_idx
ON sense.devices(tenant_id, site_id, desired_state);
CREATE UNIQUE INDEX IF NOT EXISTS sense_devices_path_name_idx
ON sense.devices(path_name) WHERE path_name <> '';
CREATE INDEX IF NOT EXISTS sense_reconcile_due_idx
ON sense.reconcile_state(next_attempt_at);
INSERT INTO sense.schema_migrations(version) VALUES (1)
ON CONFLICT (version) DO NOTHING;
+21
View File
@@ -0,0 +1,21 @@
-- Cross-schema privileges are intentionally one-way: Bell publishes one view;
-- Sense cannot read Bell source tables or write any Bell object.
REVOKE ALL ON SCHEMA bell FROM PUBLIC;
REVOKE ALL ON ALL TABLES IN SCHEMA bell FROM PUBLIC;
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA bell FROM PUBLIC;
REVOKE ALL ON SCHEMA sense FROM PUBLIC;
REVOKE ALL ON ALL TABLES IN SCHEMA sense FROM PUBLIC;
GRANT USAGE ON SCHEMA bell TO sense_app;
GRANT SELECT ON TABLE bell.site_quota_v1 TO sense_app;
REVOKE ALL ON TABLE bell.sites FROM sense_app;
REVOKE ALL ON TABLE bell.schema_migrations FROM sense_app;
REVOKE ALL ON FUNCTION bell.bump_site_version() FROM sense_app;
GRANT USAGE ON SCHEMA sense TO sense_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA sense TO sense_app;
ALTER DEFAULT PRIVILEGES FOR ROLE bell_app IN SCHEMA bell REVOKE ALL ON TABLES FROM PUBLIC;
ALTER DEFAULT PRIVILEGES FOR ROLE bell_app IN SCHEMA bell REVOKE ALL ON FUNCTIONS FROM PUBLIC;
ALTER DEFAULT PRIVILEGES FOR ROLE sense_app IN SCHEMA sense REVOKE ALL ON TABLES FROM PUBLIC;
+127
View File
@@ -0,0 +1,127 @@
-- Bell owns Area and capture-policy truth. Sense records only the highest
-- projection version it has observed and the version used for admission.
CREATE TABLE IF NOT EXISTS bell.areas (
tenant_id text NOT NULL,
site_id text NOT NULL,
id text NOT NULL,
name text NOT NULL,
capture_policy text NOT NULL DEFAULT 'video_allowed',
version bigint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
deleted_at timestamptz,
PRIMARY KEY (tenant_id, id),
CONSTRAINT bell_areas_site_fk FOREIGN KEY (tenant_id, site_id)
REFERENCES bell.sites(tenant_id, id),
CONSTRAINT bell_areas_capture_policy CHECK (
capture_policy IN ('video_allowed', 'non_imaging_only')
),
CONSTRAINT bell_areas_version_positive CHECK (version >= 1),
CONSTRAINT bell_areas_identity_not_blank CHECK (
btrim(tenant_id) <> '' AND btrim(site_id) <> ''
AND btrim(id) <> '' AND btrim(name) <> ''
)
);
ALTER TABLE bell.areas OWNER TO bell_app;
CREATE OR REPLACE FUNCTION bell.bump_area_version()
RETURNS trigger
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = pg_catalog, bell
AS $function$
BEGIN
IF NEW.tenant_id IS DISTINCT FROM OLD.tenant_id
OR NEW.site_id IS DISTINCT FROM OLD.site_id
OR NEW.id IS DISTINCT FROM OLD.id THEN
RAISE EXCEPTION 'Bell Area identity and Site are immutable';
END IF;
NEW.version := OLD.version + 1;
NEW.updated_at := clock_timestamp();
RETURN NEW;
END
$function$;
ALTER FUNCTION bell.bump_area_version() OWNER TO bell_app;
DROP TRIGGER IF EXISTS bell_areas_bump_version ON bell.areas;
CREATE TRIGGER bell_areas_bump_version
BEFORE UPDATE ON bell.areas
FOR EACH ROW EXECUTE FUNCTION bell.bump_area_version();
CREATE OR REPLACE VIEW bell.area_policy_v1 (
tenant_id,
site_id,
area_id,
capture_policy,
source_version,
source_updated_at
) AS
SELECT
area.tenant_id,
area.site_id,
area.id,
area.capture_policy,
area.version,
area.updated_at
FROM bell.areas AS area
JOIN bell.sites AS site
ON site.tenant_id = area.tenant_id AND site.id = area.site_id
WHERE area.deleted_at IS NULL AND site.deleted_at IS NULL;
ALTER VIEW bell.area_policy_v1 OWNER TO bell_app;
COMMENT ON VIEW bell.area_policy_v1 IS
'v1 read-only Area capture-policy projection owned by Bell and consumed by Sense';
ALTER TABLE sense.devices ADD COLUMN IF NOT EXISTS area_id text;
ALTER TABLE sense.devices ADD COLUMN IF NOT EXISTS area_policy_source_version bigint;
DO $constraints$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'sense.devices'::regclass
AND conname = 'sense_devices_area_not_blank'
) THEN
ALTER TABLE sense.devices ADD CONSTRAINT sense_devices_area_not_blank
CHECK (area_id IS NULL OR btrim(area_id) <> '');
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'sense.devices'::regclass
AND conname = 'sense_devices_area_version_positive'
) THEN
ALTER TABLE sense.devices ADD CONSTRAINT sense_devices_area_version_positive
CHECK (area_policy_source_version IS NULL OR area_policy_source_version >= 1);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'sense.devices'::regclass
AND conname = 'sense_devices_tenant_site_id_unique'
) THEN
ALTER TABLE sense.devices ADD CONSTRAINT sense_devices_tenant_site_id_unique
UNIQUE (tenant_id, site_id, id);
END IF;
END
$constraints$;
CREATE TABLE IF NOT EXISTS sense.area_policy_projection_state (
tenant_id text NOT NULL,
site_id text NOT NULL,
area_id text NOT NULL,
source_version bigint NOT NULL,
synced_at timestamptz NOT NULL,
PRIMARY KEY (tenant_id, site_id, area_id),
CONSTRAINT sense_area_projection_identity_not_blank CHECK (
btrim(tenant_id) <> '' AND btrim(site_id) <> '' AND btrim(area_id) <> ''
),
CONSTRAINT sense_area_projection_version_positive CHECK (source_version >= 1)
);
ALTER TABLE sense.area_policy_projection_state OWNER TO sense_app;
CREATE INDEX IF NOT EXISTS sense_devices_area_idx
ON sense.devices(tenant_id, site_id, area_id);
INSERT INTO bell.schema_migrations(version) VALUES (2)
ON CONFLICT (version) DO NOTHING;
INSERT INTO sense.schema_migrations(version) VALUES (2)
ON CONFLICT (version) DO NOTHING;
@@ -0,0 +1,67 @@
-- Local durable audit facts. Transport, signatures, acknowledgements and
-- retention belong to a later Bell relay contract.
CREATE TABLE IF NOT EXISTS sense.device_operation_outbox (
event_id text PRIMARY KEY,
event_type text NOT NULL,
tenant_id text NOT NULL,
site_id text NOT NULL,
device_id text NOT NULL,
actor_type text NOT NULL,
actor_id text NOT NULL,
reason text,
trace_id text,
aggregate_generation bigint NOT NULL,
quota_source_version bigint,
area_policy_source_version bigint,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL,
available_at timestamptz NOT NULL,
attempt_count integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz,
delivered_at timestamptz,
CONSTRAINT sense_outbox_device_fk FOREIGN KEY (tenant_id, site_id, device_id)
REFERENCES sense.devices(tenant_id, site_id, id),
CONSTRAINT sense_outbox_event_id_format CHECK (event_id ~ '^audit_[0-9a-f]{32}$'),
CONSTRAINT sense_outbox_event_type CHECK (
event_type IN ('device.created', 'device.desired_state.accepted')
),
CONSTRAINT sense_outbox_identity_not_blank CHECK (
btrim(tenant_id) <> '' AND btrim(site_id) <> ''
AND btrim(device_id) <> '' AND btrim(actor_id) <> ''
),
CONSTRAINT sense_outbox_actor_type CHECK (
actor_type IN ('user', 'service', 'system')
),
CONSTRAINT sense_outbox_generation_positive CHECK (aggregate_generation >= 1),
CONSTRAINT sense_outbox_quota_version_positive CHECK (
quota_source_version IS NULL OR quota_source_version >= 1
),
CONSTRAINT sense_outbox_area_version_positive CHECK (
area_policy_source_version IS NULL OR area_policy_source_version >= 1
),
CONSTRAINT sense_outbox_attempt_nonnegative CHECK (attempt_count >= 0),
CONSTRAINT sense_outbox_reason_length CHECK (reason IS NULL OR char_length(reason) <= 500),
CONSTRAINT sense_outbox_trace_length CHECK (trace_id IS NULL OR char_length(trace_id) <= 128),
CONSTRAINT sense_outbox_payload_object CHECK (jsonb_typeof(payload) = 'object'),
CONSTRAINT sense_outbox_payload_kind CHECK (
(event_type = 'device.created' AND payload ->> 'kind' = 'device_created')
OR (event_type = 'device.desired_state.accepted'
AND payload ->> 'kind' = 'desired_state_accepted')
),
CONSTRAINT sense_outbox_payload_redacted CHECK (
NOT (payload ?| ARRAY[
'endpoint_ref', 'credential_ref', 'profile_token', 'path_name',
'password', 'stream_uri', 'mediamtx_config'
])
)
);
ALTER TABLE sense.device_operation_outbox OWNER TO sense_app;
CREATE INDEX IF NOT EXISTS sense_outbox_delivery_idx
ON sense.device_operation_outbox(delivered_at, next_attempt_at, available_at, event_id);
CREATE INDEX IF NOT EXISTS sense_outbox_device_idx
ON sense.device_operation_outbox(tenant_id, site_id, device_id, occurred_at, event_id);
INSERT INTO sense.schema_migrations(version) VALUES (3)
ON CONFLICT (version) DO NOTHING;
@@ -0,0 +1,18 @@
-- Extend the T-009 least-privilege boundary to the Area projection and new
-- Sense-owned state. Reapplying this file is intentional and safe.
REVOKE ALL ON TABLE bell.area_policy_v1 FROM PUBLIC;
REVOKE ALL ON TABLE bell.areas FROM PUBLIC;
REVOKE ALL ON FUNCTION bell.bump_area_version() FROM PUBLIC;
GRANT USAGE ON SCHEMA bell TO sense_app;
GRANT SELECT ON TABLE bell.area_policy_v1 TO sense_app;
REVOKE ALL ON TABLE bell.areas FROM sense_app;
REVOKE ALL ON FUNCTION bell.bump_area_version() FROM sense_app;
REVOKE ALL ON TABLE sense.area_policy_projection_state FROM PUBLIC;
REVOKE ALL ON TABLE sense.device_operation_outbox FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
sense.area_policy_projection_state,
sense.device_operation_outbox
TO sense_app;
+149
View File
@@ -0,0 +1,149 @@
-- Sense Control API v1 durable concurrency, idempotency and batch-operation state.
ALTER TABLE sense.devices ADD COLUMN IF NOT EXISTS profile_token text NOT NULL DEFAULT '';
ALTER TABLE sense.devices ADD COLUMN IF NOT EXISTS resource_version bigint NOT NULL DEFAULT 1;
DO $area_preflight$
BEGIN
IF EXISTS (SELECT 1 FROM sense.devices WHERE area_id IS NULL OR btrim(area_id) = '') THEN
RAISE EXCEPTION 'assign every legacy Sense device to a valid Area before installing Control API v4';
END IF;
END
$area_preflight$;
ALTER TABLE sense.devices ALTER COLUMN area_id SET NOT NULL;
DO $constraints$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'sense.devices'::regclass
AND conname = 'sense_devices_resource_version_positive'
) THEN
ALTER TABLE sense.devices ADD CONSTRAINT sense_devices_resource_version_positive
CHECK (resource_version >= 1);
END IF;
END
$constraints$;
CREATE TABLE IF NOT EXISTS sense.control_idempotency_receipts (
scope_hash bytea PRIMARY KEY,
request_hash bytea NOT NULL,
operation_name text NOT NULL,
principal_id text NOT NULL,
tenant_id text NOT NULL,
site_id text NOT NULL,
response_status integer NOT NULL,
response_body jsonb NOT NULL,
response_etag text,
response_location text,
trace_id text NOT NULL,
created_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
CONSTRAINT sense_control_receipt_hash_lengths CHECK (
octet_length(scope_hash) = 32 AND octet_length(request_hash) = 32
),
CONSTRAINT sense_control_receipt_identity_not_blank CHECK (
btrim(operation_name) <> '' AND btrim(principal_id) <> ''
AND btrim(tenant_id) <> '' AND btrim(site_id) <> '' AND btrim(trace_id) <> ''
),
CONSTRAINT sense_control_receipt_status CHECK (response_status BETWEEN 200 AND 299),
CONSTRAINT sense_control_receipt_operation CHECK (
operation_name IN ('createDevice', 'batchSetDeviceDesiredState')
),
CONSTRAINT sense_control_receipt_lengths CHECK (
char_length(principal_id) <= 200 AND char_length(trace_id) <= 128
AND (response_etag IS NULL OR char_length(response_etag) <= 128)
AND (response_location IS NULL OR char_length(response_location) <= 512)
),
CONSTRAINT sense_control_receipt_body_object CHECK (jsonb_typeof(response_body) = 'object'),
CONSTRAINT sense_control_receipt_ttl CHECK (expires_at >= created_at + interval '24 hours')
);
ALTER TABLE sense.control_idempotency_receipts OWNER TO sense_app;
CREATE INDEX IF NOT EXISTS sense_control_receipts_expiry_idx
ON sense.control_idempotency_receipts(expires_at, scope_hash);
CREATE TABLE IF NOT EXISTS sense.batch_operations (
id text PRIMARY KEY,
tenant_id text NOT NULL,
site_id text NOT NULL,
principal_id text NOT NULL,
status text NOT NULL,
trace_id text NOT NULL,
submitted_at timestamptz NOT NULL,
completed_at timestamptz,
CONSTRAINT sense_batch_operation_id_format CHECK (
id ~ '^op_[0-9A-HJKMNP-TV-Z]{26}$'
),
CONSTRAINT sense_batch_operation_identity_not_blank CHECK (
btrim(tenant_id) <> '' AND btrim(site_id) <> ''
AND btrim(principal_id) <> '' AND btrim(trace_id) <> ''
),
CONSTRAINT sense_batch_operation_status CHECK (
status IN ('queued', 'running', 'succeeded', 'partially_succeeded', 'failed')
),
CONSTRAINT sense_batch_operation_completion CHECK (
(status IN ('queued', 'running') AND completed_at IS NULL)
OR (status IN ('succeeded', 'partially_succeeded', 'failed') AND completed_at IS NOT NULL)
)
);
ALTER TABLE sense.batch_operations OWNER TO sense_app;
CREATE TABLE IF NOT EXISTS sense.batch_operation_items (
operation_id text NOT NULL REFERENCES sense.batch_operations(id) ON DELETE CASCADE,
ordinal integer NOT NULL,
device_id text NOT NULL,
status text NOT NULL,
error_code text,
message text,
generation bigint,
PRIMARY KEY (operation_id, ordinal),
CONSTRAINT sense_batch_item_ordinal CHECK (ordinal BETWEEN 0 AND 127),
CONSTRAINT sense_batch_item_device_not_blank CHECK (btrim(device_id) <> ''),
CONSTRAINT sense_batch_item_status CHECK (
status IN ('accepted', 'rejected', 'succeeded', 'failed')
),
CONSTRAINT sense_batch_item_error_pair CHECK (
(status IN ('accepted', 'succeeded') AND error_code IS NULL AND message IS NULL)
OR (status IN ('rejected', 'failed') AND error_code IS NOT NULL AND message IS NOT NULL)
),
CONSTRAINT sense_batch_item_error_code CHECK (
error_code IS NULL OR error_code IN (
'invalid_request', 'unauthenticated', 'forbidden', 'not_found',
'conflict', 'precondition_required', 'etag_mismatch',
'idempotency_conflict', 'duplicate_serial_number', 'quota_exceeded',
'quota_projection_unavailable', 'quota_projection_invalid',
'area_policy_denied', 'area_policy_unavailable', 'adapter_not_ready',
'authentication_failed', 'endpoint_credentials_forbidden',
'batch_too_large', 'service_unavailable', 'internal_error'
)
),
CONSTRAINT sense_batch_item_message_length CHECK (
message IS NULL OR char_length(message) <= 500
),
CONSTRAINT sense_batch_item_generation CHECK (generation IS NULL OR generation >= 1)
);
ALTER TABLE sense.batch_operation_items OWNER TO sense_app;
CREATE INDEX IF NOT EXISTS sense_batch_operations_scope_idx
ON sense.batch_operations(tenant_id, site_id, submitted_at DESC, id);
ALTER TABLE sense.device_operation_outbox DROP CONSTRAINT IF EXISTS sense_outbox_event_type;
ALTER TABLE sense.device_operation_outbox ADD CONSTRAINT sense_outbox_event_type CHECK (
event_type IN (
'device.created',
'device.desired_state.accepted',
'device.configuration.accepted'
)
);
ALTER TABLE sense.device_operation_outbox DROP CONSTRAINT IF EXISTS sense_outbox_payload_kind;
ALTER TABLE sense.device_operation_outbox ADD CONSTRAINT sense_outbox_payload_kind CHECK (
(event_type = 'device.created' AND payload ->> 'kind' = 'device_created')
OR (event_type = 'device.desired_state.accepted'
AND payload ->> 'kind' = 'desired_state_accepted')
OR (event_type = 'device.configuration.accepted'
AND payload ->> 'kind' = 'configuration_accepted')
);
INSERT INTO sense.schema_migrations(version) VALUES (4)
ON CONFLICT (version) DO NOTHING;
@@ -0,0 +1,11 @@
-- Extend the least-privilege boundary to Control API durable state.
REVOKE ALL ON TABLE sense.control_idempotency_receipts FROM PUBLIC;
REVOKE ALL ON TABLE sense.batch_operations FROM PUBLIC;
REVOKE ALL ON TABLE sense.batch_operation_items FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
sense.control_idempotency_receipts,
sense.batch_operations,
sense.batch_operation_items
TO sense_app;
+147
View File
@@ -0,0 +1,147 @@
-- Add fenced reconciliation claims and durable, secret-free MediaMTX path
-- ownership/orphan reports. Runtime migrations are installed out of process.
ALTER TABLE sense.reconcile_state
ADD COLUMN IF NOT EXISTS lease_owner text,
ADD COLUMN IF NOT EXISTS lease_token text,
ADD COLUMN IF NOT EXISTS lease_until timestamptz;
DO $constraints$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'sense.reconcile_state'::regclass
AND conname = 'sense_reconcile_lease_all_or_none'
) THEN
ALTER TABLE sense.reconcile_state
ADD CONSTRAINT sense_reconcile_lease_all_or_none CHECK (
(lease_owner IS NULL AND lease_token IS NULL AND lease_until IS NULL)
OR (btrim(lease_owner) <> '' AND btrim(lease_token) <> '' AND lease_until IS NOT NULL)
);
END IF;
END
$constraints$;
CREATE INDEX IF NOT EXISTS sense_reconcile_lease_due_idx
ON sense.reconcile_state(lease_until, next_attempt_at);
CREATE TABLE IF NOT EXISTS sense.media_path_ownership (
path_name text PRIMARY KEY,
device_id text NOT NULL,
tenant_id text NOT NULL,
site_id text NOT NULL,
first_claimed_at timestamptz NOT NULL,
last_confirmed_at timestamptz NOT NULL,
CONSTRAINT sense_media_path_ownership_not_blank CHECK (
btrim(path_name) <> '' AND btrim(device_id) <> ''
AND btrim(tenant_id) <> '' AND btrim(site_id) <> ''
)
);
ALTER TABLE sense.media_path_ownership OWNER TO sense_app;
-- Keep the historical row independent from device lifecycle so a later device
-- deletion cannot erase the evidence needed to classify an owned stale path.
INSERT INTO sense.media_path_ownership(
path_name, device_id, tenant_id, site_id, first_claimed_at, last_confirmed_at
)
SELECT d.path_name, d.id, d.tenant_id, d.site_id, d.created_at, d.updated_at
FROM sense.devices d
WHERE btrim(d.path_name) <> ''
AND EXISTS (
SELECT 1 FROM sense.device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture'
)
ON CONFLICT (path_name) DO UPDATE SET
device_id = EXCLUDED.device_id,
tenant_id = EXCLUDED.tenant_id,
site_id = EXCLUDED.site_id,
last_confirmed_at = GREATEST(
sense.media_path_ownership.last_confirmed_at,
EXCLUDED.last_confirmed_at
);
CREATE TABLE IF NOT EXISTS sense.operational_leases (
lease_name text PRIMARY KEY,
owner_id text NOT NULL,
fencing_token text NOT NULL,
lease_until timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CONSTRAINT sense_operational_lease_not_blank CHECK (
btrim(lease_name) <> '' AND btrim(owner_id) <> '' AND btrim(fencing_token) <> ''
)
);
ALTER TABLE sense.operational_leases OWNER TO sense_app;
CREATE TABLE IF NOT EXISTS sense.orphan_scan_runs (
id text PRIMARY KEY,
instance_id text NOT NULL,
observed_count integer NOT NULL,
owned_stale_count integer NOT NULL,
unowned_count integer NOT NULL,
safety_allowed boolean NOT NULL,
safety_reason text NOT NULL,
completed_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
CONSTRAINT sense_orphan_scan_id CHECK (id ~ '^scan_[0-9A-HJKMNP-TV-Z]{26}$'),
CONSTRAINT sense_orphan_scan_counts CHECK (
observed_count >= 0 AND owned_stale_count >= 0 AND unowned_count >= 0
AND owned_stale_count + unowned_count <= observed_count
),
CONSTRAINT sense_orphan_scan_not_blank CHECK (
btrim(instance_id) <> '' AND btrim(safety_reason) <> ''
),
CONSTRAINT sense_orphan_scan_expiry CHECK (expires_at > completed_at)
);
ALTER TABLE sense.orphan_scan_runs OWNER TO sense_app;
CREATE TABLE IF NOT EXISTS sense.orphan_scan_findings (
scan_id text NOT NULL REFERENCES sense.orphan_scan_runs(id) ON DELETE CASCADE,
path_name text NOT NULL,
classification text NOT NULL,
device_id text,
PRIMARY KEY (scan_id, path_name),
UNIQUE (scan_id, path_name, classification),
CONSTRAINT sense_orphan_finding_classification CHECK (
classification IN ('owned_stale', 'unowned')
),
CONSTRAINT sense_orphan_finding_not_blank CHECK (
btrim(path_name) <> '' AND (device_id IS NULL OR btrim(device_id) <> '')
),
CONSTRAINT sense_orphan_finding_owner_shape CHECK (
(classification = 'owned_stale' AND device_id IS NOT NULL)
OR (classification = 'unowned' AND device_id IS NULL)
)
);
ALTER TABLE sense.orphan_scan_findings OWNER TO sense_app;
CREATE TABLE IF NOT EXISTS sense.orphan_cleanup_actions (
scan_id text NOT NULL REFERENCES sense.orphan_scan_runs(id),
path_name text NOT NULL,
classification text NOT NULL DEFAULT 'owned_stale',
actor_id text NOT NULL,
status text NOT NULL,
error_code text,
attempted_at timestamptz NOT NULL,
PRIMARY KEY (scan_id, path_name),
FOREIGN KEY (scan_id, path_name, classification)
REFERENCES sense.orphan_scan_findings(scan_id, path_name, classification),
CONSTRAINT sense_orphan_cleanup_owned_only CHECK (classification = 'owned_stale'),
CONSTRAINT sense_orphan_cleanup_status CHECK (status IN ('deleted', 'failed')),
CONSTRAINT sense_orphan_cleanup_not_blank CHECK (
btrim(path_name) <> '' AND btrim(actor_id) <> ''
AND (error_code IS NULL OR btrim(error_code) <> '')
),
CONSTRAINT sense_orphan_cleanup_error_shape CHECK (
(status = 'deleted' AND error_code IS NULL)
OR (status = 'failed' AND error_code IS NOT NULL)
)
);
ALTER TABLE sense.orphan_cleanup_actions OWNER TO sense_app;
CREATE INDEX IF NOT EXISTS sense_orphan_scan_completed_idx
ON sense.orphan_scan_runs(completed_at);
CREATE INDEX IF NOT EXISTS sense_orphan_cleanup_status_idx
ON sense.orphan_cleanup_actions(scan_id, status);
INSERT INTO sense.schema_migrations(version) VALUES (5)
ON CONFLICT (version) DO NOTHING;
@@ -0,0 +1,17 @@
-- Extend the least-privilege boundary to reconciliation and orphan state.
REVOKE ALL ON TABLE
sense.media_path_ownership,
sense.operational_leases,
sense.orphan_scan_runs,
sense.orphan_scan_findings,
sense.orphan_cleanup_actions
FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
sense.media_path_ownership,
sense.operational_leases,
sense.orphan_scan_runs,
sense.orphan_scan_findings,
sense.orphan_cleanup_actions
TO sense_app;
+30
View File
@@ -0,0 +1,30 @@
# YoVision PostgreSQL 初始化
本目录实现 T-009~T-012 的 PostgreSQL `17.10` schema。SQL 必须按文件名前缀顺序执行:`001`~`004` 创建 NOLOGIN 权限角色、Bell/Sense 初始对象和配额权限;`005`~`007` 增量增加 Area 与审计;`008`~`009` 增加 Control API 状态;`010`~`011` 增加调和 fencing、MediaMTX Path 历史归属、孤儿报告/受控处置结果和对应最小权限。全部 SQL 可重放。应用登录角色及其密码由部署环境或密钥系统创建,并分别加入 `bell_app`、`sense_app`;仓库不保存登录凭据。
生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志:
安装 `008` 前必须先查询并为全部历史 `sense.devices.area_id IS NULL/blank` 记录完成经批准的 Area 归属;migration 会失败关闭而不会猜测或自动搬迁这些设备。
```powershell
$env:YOVISION_PG_ADMIN_DSN = '由管理员私下设置'
Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql |
Sort-Object Name |
ForEach-Object { & 'D:\pgsql17\bin\psql.exe' -X -v ON_ERROR_STOP=1 -d $env:YOVISION_PG_ADMIN_DSN -f $_.FullName }
```
不要把该示例替换成真实连接串后提交。自动验收使用 [`scripts/test_postgres.ps1`](../../scripts/test_postgres.ps1) 启动隔离临时集群,不读取或修改 `D:\pgsql17\data`。
## 权限边界
- `bell_app` 拥有 `bell.sites`/`bell.areas`、版本 trigger、`bell.site_quota_v1` 和 `bell.area_policy_v1`。
- `sense_app` 拥有 `sense` schema,只获得 `bell` schema 的 `USAGE` 和两个投影视图的 `SELECT`。
- `sense_app` 对 Bell 源表、Bell migration 表和 trigger function 没有权限;启动检查发现权限过宽时拒绝运行。
- `sense.device_operation_outbox` 是本地持久化审计事实,不是 Bell 全局审计真相;relay 的传输、签名、确认和留存尚未实现。
- `sense.control_idempotency_receipts` 不保存原始 Idempotency-Key,只保存 scope/request SHA-256 和脱敏响应快照;`batch_operations`/items 只保存逻辑 ID、状态和稳定错误,不保存连接秘密。
- 调和与孤儿租约使用 PostgreSQL `clock_timestamp()` 和 fencing token;过期 worker 不能提交完成/失败或扫描报告。`media_path_ownership`、扫描和处置表不保存 endpoint、credential 或 source URI;数据库约束禁止为 `unowned` finding 写删除结果。
- PUBLIC 对两个业务 schema 的表和函数没有权限。
## 恢复
T-009~T-012 不迁移或删除现有 SQLite 数据,默认运行配置也仍为 SQLite。SQLite 不具备生产 Area/Outbox/Control API、多实例租约或孤儿处置一致性,业务路由不会在 SQLite 上注册。若 PostgreSQL 切换验证失败,先关闭 Control API 和孤儿扫描;需要实验室回退时停止 PostgreSQL 模式进程并恢复 SQLite DSN,不得误报为生产语义等价。schema 不提供自动 down migration;需要移除时由管理员先备份,再只删除明确确认的 YoVision 专用数据库,禁止对共享实例做模糊或递归清理。
+181
View File
@@ -0,0 +1,181 @@
\set ON_ERROR_STOP on
DO $assertions$
DECLARE
actual_columns text[];
default_quota integer;
initial_version bigint;
updated_version bigint;
area_initial_version bigint;
area_updated_version bigint;
BEGIN
IF current_setting('server_version_num')::integer < 170000
OR current_setting('server_version_num')::integer >= 180000 THEN
RAISE EXCEPTION 'YoVision requires PostgreSQL 17.x';
END IF;
SELECT array_agg(column_name ORDER BY ordinal_position)
INTO actual_columns
FROM information_schema.columns
WHERE table_schema = 'bell' AND table_name = 'site_quota_v1';
IF actual_columns IS DISTINCT FROM ARRAY[
'tenant_id', 'site_id', 'max_video_channels', 'source_version', 'source_updated_at'
] THEN
RAISE EXCEPTION 'site_quota_v1 signature drift: %', actual_columns;
END IF;
IF NOT has_table_privilege('sense_app', 'bell.site_quota_v1', 'SELECT') THEN
RAISE EXCEPTION 'sense_app lacks SELECT on quota view';
END IF;
IF has_table_privilege('sense_app', 'bell.site_quota_v1', 'INSERT')
OR has_table_privilege('sense_app', 'bell.site_quota_v1', 'UPDATE')
OR has_table_privilege('sense_app', 'bell.site_quota_v1', 'DELETE') THEN
RAISE EXCEPTION 'sense_app has forbidden quota-view write privileges';
END IF;
IF has_table_privilege('sense_app', 'bell.sites', 'SELECT')
OR has_table_privilege('sense_app', 'bell.sites', 'INSERT')
OR has_table_privilege('sense_app', 'bell.sites', 'UPDATE')
OR has_table_privilege('sense_app', 'bell.sites', 'DELETE') THEN
RAISE EXCEPTION 'sense_app has forbidden Bell source-table privileges';
END IF;
SELECT array_agg(column_name ORDER BY ordinal_position)
INTO actual_columns
FROM information_schema.columns
WHERE table_schema = 'bell' AND table_name = 'area_policy_v1';
IF actual_columns IS DISTINCT FROM ARRAY[
'tenant_id', 'site_id', 'area_id', 'capture_policy',
'source_version', 'source_updated_at'
] THEN
RAISE EXCEPTION 'area_policy_v1 signature drift: %', actual_columns;
END IF;
IF NOT has_table_privilege('sense_app', 'bell.area_policy_v1', 'SELECT') THEN
RAISE EXCEPTION 'sense_app lacks SELECT on Area policy view';
END IF;
IF has_table_privilege('sense_app', 'bell.area_policy_v1', 'INSERT')
OR has_table_privilege('sense_app', 'bell.area_policy_v1', 'UPDATE')
OR has_table_privilege('sense_app', 'bell.area_policy_v1', 'DELETE')
OR has_table_privilege('sense_app', 'bell.areas', 'SELECT')
OR has_table_privilege('sense_app', 'bell.areas', 'INSERT')
OR has_table_privilege('sense_app', 'bell.areas', 'UPDATE')
OR has_table_privilege('sense_app', 'bell.areas', 'DELETE') THEN
RAISE EXCEPTION 'sense_app violates the Area projection boundary';
END IF;
IF has_function_privilege('sense_app', 'bell.bump_area_version()', 'EXECUTE') THEN
RAISE EXCEPTION 'sense_app can execute the Bell Area version trigger function';
END IF;
IF NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'SELECT')
OR NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'INSERT')
OR NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'UPDATE')
OR NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'DELETE') THEN
RAISE EXCEPTION 'sense_app lacks access to its local audit Outbox';
END IF;
IF (SELECT max(version) FROM bell.schema_migrations) <> 2
OR (SELECT max(version) FROM sense.schema_migrations) <> 5 THEN
RAISE EXCEPTION 'schema migration version drift';
END IF;
INSERT INTO bell.sites(tenant_id, id, name)
VALUES ('assertion-tenant', 'assertion-site', 'Assertion Site')
RETURNING max_video_channels, version INTO default_quota, initial_version;
IF default_quota <> 16 OR initial_version <> 1 THEN
RAISE EXCEPTION 'Bell site default quota/version drift';
END IF;
UPDATE bell.sites SET name = 'Assertion Site Updated'
WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-site'
RETURNING version INTO updated_version;
IF updated_version <> initial_version + 1 THEN
RAISE EXCEPTION 'Bell site source_version is not monotonic';
END IF;
INSERT INTO bell.areas(tenant_id, site_id, id, name, capture_policy)
VALUES (
'assertion-tenant', 'assertion-site', 'assertion-area',
'Assertion Area', 'video_allowed'
)
RETURNING version INTO area_initial_version;
UPDATE bell.areas SET capture_policy = 'non_imaging_only'
WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-area'
RETURNING version INTO area_updated_version;
IF area_initial_version <> 1 OR area_updated_version <> area_initial_version + 1 THEN
RAISE EXCEPTION 'Bell Area source_version is not monotonic';
END IF;
BEGIN
INSERT INTO bell.areas(tenant_id, site_id, id, name, capture_policy)
VALUES ('assertion-tenant', 'assertion-site', 'invalid-area', 'Invalid', 'unknown');
RAISE EXCEPTION 'unknown capture_policy unexpectedly accepted';
EXCEPTION
WHEN check_violation THEN NULL;
END;
BEGIN
INSERT INTO bell.sites(tenant_id, id, name, max_video_channels)
VALUES ('assertion-tenant', 'invalid-site', 'Invalid', 129);
RAISE EXCEPTION 'quota 129 unexpectedly accepted';
EXCEPTION
WHEN check_violation THEN NULL;
END;
DELETE FROM bell.areas
WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-area';
DELETE FROM bell.sites
WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-site';
END
$assertions$;
DO $control_api$
BEGIN
IF NOT has_table_privilege('yovision_t012_sense', 'sense.control_idempotency_receipts', 'SELECT,INSERT,UPDATE,DELETE')
OR NOT has_table_privilege('yovision_t012_sense', 'sense.batch_operations', 'SELECT,INSERT,UPDATE,DELETE')
OR NOT has_table_privilege('yovision_t012_sense', 'sense.batch_operation_items', 'SELECT,INSERT,UPDATE,DELETE') THEN
RAISE EXCEPTION 'Sense runtime lacks Control API table privileges';
END IF;
IF has_table_privilege('public', 'sense.control_idempotency_receipts', 'SELECT')
OR has_table_privilege('public', 'sense.batch_operations', 'SELECT') THEN
RAISE EXCEPTION 'Control API durable state leaked to PUBLIC';
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'sense' AND table_name = 'devices'
AND column_name = 'resource_version'
) OR NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'sense' AND table_name = 'devices'
AND column_name = 'profile_token'
) THEN
RAISE EXCEPTION 'Control API device columns are missing';
END IF;
END
$control_api$;
DO $reconcile_safety$
DECLARE
target text;
BEGIN
FOREACH target IN ARRAY ARRAY[
'sense.media_path_ownership',
'sense.operational_leases',
'sense.orphan_scan_runs',
'sense.orphan_scan_findings',
'sense.orphan_cleanup_actions'
] LOOP
IF NOT has_table_privilege('yovision_t012_sense', target, 'SELECT,INSERT,UPDATE,DELETE') THEN
RAISE EXCEPTION 'Sense runtime lacks reconciliation safety privilege on %', target;
END IF;
IF has_table_privilege('public', target, 'SELECT,INSERT,UPDATE,DELETE') THEN
RAISE EXCEPTION 'Reconciliation safety state leaked to PUBLIC on %', target;
END IF;
END LOOP;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'sense' AND table_name = 'reconcile_state'
AND column_name = 'lease_token'
) THEN
RAISE EXCEPTION 'reconcile fencing columns are missing';
END IF;
END
$reconcile_safety$;
+12 -6
View File
@@ -40,19 +40,19 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI
## 当前阶段
当前为 **M0:兼容性验证 + 需求定稿**。
当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,正在转入 M3 的 Bell/Brain 最小闭环**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置,不阻塞 Bell 事件存储与审计 relay 边界设计。
优先路径:
1. M0:3–5 款摄像头跑通 ONVIF 核心操作,形成采购白名单;关闭架构影响型开放问题。
2. M1:只在 `Sense/` 建立 MediaMTX 生产接入骨架,5 路自动建 path、探活、断线重建。
3. M2:对账、多租户、隧道和至少一个站点的 16 路全流程。
1. M0:已用一台真实样机完成首期指定“型号 + 硬件版本 + 固件”ONVIF/RTSP 准入;结论不外推为多品牌兼容,真实断网恢复证据按负责人豁免留痕。
2. M1:只在 `Sense/` 建立 MediaMTX 生产接入骨架,以 1 路准入实机 + 至少 4 路独立合成源完成五路自动建 path、探活和断线重建。
3. M2:Control API、多租户投影、调和 fencing、孤儿安全闸和本地 16 路批量收敛/30 分钟稳定基线已完成;WireGuard 等客户网络条件具备后补验。
4. M3:Brain + Bell 起步,默认 16 路端到端事件、预警、ack 与误报反馈。
5. M4–M5:64/128 路分片、管理端和第二/第三场景包。
## 任务领取与状态
- Gitea Issue 是实时状态权威,状态标签为 `status/todo`、`status/doing`、`status/blocked`、`status/review`、`status/done`。
- Gitea Issue 是实时状态权威,状态标签为 `status/waiting`、`status/todo`、`status/doing`、`status/blocked`、`status/review`、`status/done`;`waiting` 表示尚未领取且因依赖或外部条件不可领取。
- 任务文件保存不可变规格、依赖、写路径、验证门禁和执行证据。
- 一个 agent 同时最多一个活跃任务;一个任务同时只有一个写入者。
- dispatcher 创建 `claims/T-<编号>` 和 `agent/<agent-id>/T-<编号>` 后,worker 必须读回确认。
@@ -85,6 +85,12 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI
python scripts/validate_agent_context.py
python -m unittest discover -s tests -p "test_*.py"
python scripts/validate_harness_governance.py
go -C Sense generate ./internal/mtx ./internal/controlapi
go -C Sense test ./...
go -C Sense vet ./...
go -C Sense build ./...
```
当前没有生产代码构建命令。代码出现后,以 [`03-tech-stack.md`](03-tech-stack.md) 的验证矩阵和当前任务门禁为准。
日常优先运行根目录 `./init.ps1` 或 `./init.sh`,它会执行上述治理、生成、测试、静态检查和构建门禁。Sense 本地启动为 `go -C Sense run ./cmd/sense-api`;默认只监听回环地址,具体配置、MediaMTX 版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md) 和 [`../Sense/README.md`](../Sense/README.md)。
本机 16 路软件容量基线使用 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17`;正式证据必须使用默认 30 分钟窗口,且只证明固定低码率合成负载。结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
+14 -2
View File
@@ -18,20 +18,25 @@
## 2. 当前 M0 验收
- 在隔离实验室运行 MiBeeNvr 等现成测试台,不改 `_reference/`,不接真实客户摄像头。
- 选择 3–5 款候选摄像头,逐款通过 `GetProfiles`、`GetStreamUri`、`SetSystemDateAndTime`。
- 记录断线恢复、认证失败、时间漂移、主/子码流和厂商差异,形成采购白名单。
- 首期统一采购一个指定摄像头型号;至少使用一台真实样机,对冻结的“厂商 + 型号 + 硬件版本 + 固件版本”组合通过 `GetProfiles`、`GetStreamUri`、`SetSystemDateAndTime`。
- 记录认证失败、时间漂移、主/子码流各至少 10 分钟以及至少 3 次断线恢复,形成指定型号准入记录。结论不得外推到其他型号/固件、生产批次或多品牌兼容;基线变化必须重新验证。
- 执行 `raw/01-需求收集.md` §8 已批准的决策台账;只将明确记录的下游法务、客户、硬件和供应商门禁留到对应里程碑。
- M0 代码与临时配置可丢弃,不作为生产基线;可借鉴范围严格遵循 NVR 白名单。
本地硬件开发基线为现有一台 Hikvision IP Camera,精确型号/硬件/固件由 T-001 冻结;生产代码仍只依赖标准 ONVIF/RTSP adapter,不得写死海康品牌或私有地址。M1 实验室使用该 1 路实机和至少 4 条可独立启停的合成 RTSP 上游完成五路软件闭环。合成源可用于功能、配额和容量测试,但不能证明多台真实设备故障隔离、批次一致性或生产 SLA;这些结论由客户授权、借用或租赁设备的 T-007 现场门禁提供。
## 3. P0 功能要求
### 3.1 接入与设备
- 支持标准 ONVIF/RTSP,不绑定摄像头品牌。
- 台账与管理端以“设备”为根实体,使用 `modality` 表达 video / radar / contact / button / wearable / other,并使用 `capabilities` 决定是否展示画面、媒体、空间配置、遥测等能力;M1~M5 只完整实现 video 适配器,M6 再接入非视频设备,但不得因此把数据模型和一级信息架构写死为摄像头。
- 支持 NAT 后的边缘主动推流;设备身份使用稳定序列号而非 IP。
- 批量开通不依赖逐路手工操作,支持待激活中间态。
- 探活、离线告警、开通校时、断线自动恢复。
- 容量写入时校验站点配额;配额服务不可用时拒绝新增/启用,但不影响已有流。
- Site、Area 与隐私准入策略由 Bell 统一持有;`capture_policy = video_allowed | non_imaging_only` 通过版本化内部 API 或只读投影提供给 Sense。Sense 在设备新增/启用时依据设备成像能力校验,并显示策略版本/同步状态;策略读取失败时拒绝新的成像设备变更并告警,已有链路不静默停用或伪装为已收敛。
- ONVIF 能力探测必须持久化厂商、型号、固件、认证方式、Profiles、校时与事件订阅结果以及探测时间;UI 区分探测值和最终生效值。M6 前未实现的非视频协议适配器使用 `adapter_not_ready`,不得伪造在线状态或遥测。
### 3.2 分析与规则
@@ -51,8 +56,13 @@
- 预警必须有 ack;未 ack 自动升级,进程重启后能续跑。
- 升级链、超时、联系人和时段可按租户/站点配置。
- 联系人与值班排班必须统一设计并共享人员、值班组和已验证通知通道主数据,但分对象、分版本管理:联系人不承载轮换字段,排班不复制手机号;升级步骤通过类型化目标引用指定人员、值班组或排班计划,不写死号码。
- 值班排班至少覆盖站点时区、周轮换、生效日期、临时替班、空档/重叠冲突检查、当前与未来值班人预览、版本发布和审计。每次投递创建时解析当时生效的排班版本,并固化实际收件人、通道与解析版本快照;后续修改不得改写历史投递事实。
- 至少两条独立投递路径,其中一条可绕过互联网。
- 区分已发出、已送达、已看到;没有回执不能当成功。
- Alert 与 Event 保持可导航的多对多关系;被规则抑制且没有创建 Alert 的 Event 不伪装为已投递。
- 班次交接覆盖未 ack、处置中与升级中的 Alert;接班确认留痕,交接过程不暂停或重置升级链。
- 交接班只显式转移进行中 Alert 的处置责任,不静默修改未来排班;未来班次替换通过排班临时替班并发布新版本完成。
- 静默必须限时且自动恢复,单次不超过 4 小时,无永久静默。
- 业务预警与运维告警使用不同通道和值班配置。
@@ -61,12 +71,14 @@
- 多租户数据、账号、配置和存储隔离;最小 RBAC 为平台管理员、租户管理员、站点管理员、值班员、只读。
- 全链路审计,预警生命周期可追溯。
- Prometheus/Grafana 至少覆盖设备在线、流状态、推理延迟、事件量、未收敛项和投递 SLA。
- Sense 提供面向接入运维的运维中心,聚合对账差异、重试退避、孤儿安全闸、媒体/推理分片、边缘隧道/补传和运维告警;它不承担 Bell 的业务预警和全局管理审计。
- 断网时边缘缓存事件,恢复后补传。
- 不在代码、日志、证据文件名和工单中泄露摄像头凭据、客户名或敏感地址。
### 3.6 管理端与外部集成
- Bell 自研并持有事件、Alert、ack、升级链、租户和审计真相;客户既有平台不得成为这些状态的唯一真相源。
- Bell 管理端持有 Tenant、Site、Area、RBAC、配额和全局审计真相;Sense 只消费当前租户/站点/角色上下文和带版本的配额/Area 策略投影。Area 策略变更与已有成像设备冲突时必须显式迁移或取消,禁止静默停用。
- 对外使用版本化 OpenAPI/Webhook;M3 客户端为值班室 Web + 响应式移动 H5,可嵌入客户系统。
- 投递层必须使用供应商无关的 provider 接口;试点至少有本地声光/Web 与一条短信或语音,生产前补齐两条独立路径及故障切换。
+44 -4
View File
@@ -8,6 +8,7 @@
| --- | --- | --- |
| 首期部署 | 客户侧私有化实例 | 保留 `tenant_id`、RBAC、schema 和 API 的 SaaS-ready 隔离边界 |
| M1–M3 视频接入 | ONVIF / RTSP | 售前盘点存量 NVR;GB/T 28181-2022 不进当前 MVP,按需另建适配任务 |
| 本地摄像头基线 | 1 台 Hikvision IP Camera + 独立合成 RTSP 源 | 海康精确型号/固件由 T-001 冻结;T-006 冻结合成 publisher 及版本。仅为开发策略,不在业务代码写死品牌,也不替代 T-007 真实多路现场验收 |
| Sense | Go | 设备、ONVIF、MediaMTX 控制与对账 |
| 媒体数据面 | MediaMTX 独立二进制 | MIT;M1 正式生产基线 |
| MediaMTX API | 从官方 OpenAPI 用 `oapi-codegen` 生成 + 薄封装 | 不依赖第三方非官方 SDK |
@@ -28,6 +29,33 @@
| 指标 | Prometheus + Grafana | 三系统统一可观测入口 |
| 追踪 | OpenTelemetry + Jaeger | 端到端事件链路 |
### 1.1 Sense M1 冻结版本(T-003)
| 组件 | 冻结版本 | 许可证 / 校验 | 升级与退出路线 |
| --- | --- | --- | --- |
| Go | `1.26.5`(`go 1.26.0` + `toolchain go1.26.5`) | BSD-3-Clause;从 `go.dev/dl` 校验,Windows amd64 ZIP SHA-256 `97e6b2a833b6d89f9ff17d25419ac0a7e3b482a044e9ab18cdef834bd834fd38` | 跟随仍受支持的 Go 小版本,先在 CI/目标平台跑全量测试再升级;标准 Go module,无私有运行时绑定 |
| SQLite driver | `modernc.org/sqlite v1.54.0` | BSD-3-Clause;module sum `h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=`,go.mod sum `h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=` | 选择无 CGO driver 以简化 Windows/边缘部署;只经 `database/sql` 与 repository 使用,可替换 driver;生产仍迁移到 PostgreSQL `sense` schema |
| MediaMTX | `v1.19.3` | MIT;官方 release `checksums.sha256`:Windows amd64 `5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe`、Linux amd64 `a7ba21268fccda3ebc43fdad76b87fddb85ce77e725b5cb637bca724b5394fbe`、Linux arm64 `9e5b38a5b5fcab1916341b024031b2fc5dc6a2059baed9ba3f3b0d3768d231a8` | 独立进程,不链接到 Sense;升级时先更新 vendored OpenAPI、重新生成并跑假服务契约测试;可通过 `mtx` port 更换媒体数据面 |
| oapi-codegen | `v2.8.0` | Apache-2.0;module sum `h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM=`,go.mod sum `h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo=` | 仅为构建工具;版本锁在 module tool dependency,生成文件与薄封装分离;升级后必须重新生成并检查 diff |
| oapi-codegen runtime | `v1.6.0` | Apache-2.0;module sum `h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU=`,go.mod sum `h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=` | 生成客户端的最小运行时;与生成器一起升级并跑假 HTTP 契约测试,退出时随生成客户端一并替换 |
MediaMTX 官方 `v1.19.3` OpenAPI 固定保存于 `Sense/api/vendor/mediamtx-v1.19.3.openapi.yaml`,SHA-256 为 `a2b58195f1ec76541e124b5de4ee54645e5a3e25f70c4a73acc4a44d6f2b9c52`。下载二进制后必须对照上表或官方同版 `checksums.sha256`,不得使用浮动 `latest` URL。SQLite `v1.56.0` 在本决策日刚发布,T-003 不追新;后续依赖升级单独评审。
### 1.2 Sense M2 PostgreSQL 冻结版本(T-009)
| 组件 | 冻结版本 | 许可证 / 校验 | 使用与退出路线 |
| --- | --- | --- | --- |
| PostgreSQL | `17.10` | PostgreSQL License;本机验收二进制 `D:\pgsql17\bin\postgres.exe` 与 `psql.exe` 均报告 `17.10` | 首期一个实例、`sense`/`bell` schema 分离;自动测试用同版二进制启动隔离临时集群,不改现有服务。升级 17.x 小版本须重跑 migration、权限和 repository 集成测试;跨大版本先做备份恢复演练 |
| Go PostgreSQL driver | `github.com/jackc/pgx/v5 v5.10.0` 的 `stdlib` | MIT;module sum `h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=`,go.mod sum `h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=` | 只通过 `database/sql` 和 store port 使用;DSN 只来自进程环境且不记录。退出时可更换 `database/sql` driver,业务域与调和器不依赖 pgx 类型 |
M2 仍保留 SQLite 作为单机开发与 T-006 回归入口,但生产真相源切换目标是 PostgreSQL。运行时必须显式设置 `SENSE_DB_DRIVER=postgres` 和私有 `SENSE_DB_DSN` 才使用 PostgreSQL;仓库不提供带密码 DSN。初始 schema 由 `deploy/postgres/` 的顺序 migration 创建,不在 Sense 进程启动时使用高权限自动迁移。
T-011 复用同一套冻结的 Go、PostgreSQL、pgx 与 `oapi-codegen`,未增加第三方依赖。Sense Control API v1 从仓库 OpenAPI 生成 `net/http` server glue;首版认证是可替换 port 下的外部静态 SHA-256 注册表,公共路由默认关闭且只在 PostgreSQL 生产路径启用。T-011 首次落成 v4 schema,T-012 后进程统一要求 v5。静态适配器是私有部署启动方案,不替代后续 Bell/JWT/OIDC 身份服务。
T-012 同样未增加第三方依赖:PostgreSQL v5 使用数据库时钟租约和 fencing token 协调多实例调和/孤儿扫描;MediaMTX Path 枚举继续使用同版生成客户端。`/metrics` 由 Go 标准库输出 Prometheus 文本格式,只包含固定结果枚举、构建/实例信息和汇总 gauge/counter,不使用 tenant、Site、device 或 Path 标签。孤儿处置是 PostgreSQL 专用本地命令,默认周期任务只报告、不自动删除。
T-014 没有增加生产依赖。Windows 容量脚本冻结并核对 Sense 模块 Go 1.26.5、PostgreSQL 17.10、MediaMTX v1.19.3 与 FFmpeg 8.1.2;MediaMTX Windows amd64 ZIP 使用上表官方 SHA-256,解压后 `mediamtx.exe` 另固定为 `1cda85249312cb9463f9f94c5a712b9f160c9af3fd9490f0d4723911d7880e05`。FFmpeg 只生成无人物夹具并以 16 个独立 `-c copy` 进程发布,不进入生产镜像或 Go module。正式本机结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
## 2. 外部项目边界
- MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。
@@ -37,7 +65,7 @@
## 3. 待冻结项
- Go、Python、PostgreSQL、MediaMTX、Savant/DeepStream 的精确版本。
- Python、Savant/DeepStream 的精确版本;Go、MediaMTX 与 PostgreSQL 已分别为 Sense M1/M2 冻结,后续阶段可按升级流程调整。
- Bell 前端框架和组件库。
- 事件投递 transport 从 HTTP 起步还是直接采用消息总线。
- 目标 GPU/边缘硬件、解码能力和每 worker 的 `max_sources`。
@@ -48,7 +76,7 @@
## 4. 当前标准入口
仓库当前只有文档和契约,未产生可构建生产代码。根目录脚本执行文档治理验证:
Sense M1 骨架建立后,根目录脚本同步 Go 依赖并执行治理与 Sense 验证:
```powershell
./init.ps1
@@ -60,6 +88,17 @@ WSL/Linux/macOS/Git Bash:
./init.sh
```
Sense 单独执行:
```powershell
go -C Sense mod download
go -C Sense generate ./internal/mtx ./internal/controlapi
go -C Sense test ./...
go -C Sense vet ./...
go -C Sense build ./...
go -C Sense run ./cmd/sense-api
```
直接验证:
```powershell
@@ -74,9 +113,10 @@ python scripts/validate_harness_governance.py
| --- | --- | --- | --- |
| Harness 文档/任务/Gitea 模板 | 上述三条 Python 命令 | 任一治理协议、清单或任务 schema 变化 | 不适用 |
| `docs/raw/contracts/` | JSON Schema 校验 + 契约代码断言(实现后补命令) | schema/示例/mapper 任一变化 | 生产者与消费者联合评审 |
| Sense Go | `go test ./...`、`go vet ./...`(代码出现后) | ONVIF、存储、MediaMTX、对账或公共 API 变化 | 命中设备任务时使用指定摄像头矩阵 |
| Sense Go | `go -C Sense generate ./internal/mtx ./internal/controlapi`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` | ONVIF、存储、MediaMTX、对账或公共 API 变化 | T-006 使用 1 路指定实机 + 4 路独立合成源;T-007 才要求客户/借用/租赁的真实多路矩阵 |
| PostgreSQL schema/repository | `python -m unittest discover -s tests -p "test_postgres_contract.py"`;Windows 本机再运行 `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` | migration、权限、配额判定或 PostgreSQL driver 变化 | 不需要摄像头;必须核对临时集群未使用现有 data 目录,现有 5432 listener 前后不变 |
| Brain Python | 单元测试、类型/格式检查(命令待项目脚手架冻结) | mapper、判定状态机、模型接口变化 | 命中模型任务时用冻结数据集和目标硬件 |
| Bell Go/Web | 后端测试 + 前端 lint/test/build(命令待脚手架冻结) | schema、RBAC、预警状态机或公共 UI 变化 | P0 流程由产品/值班角色验收 |
| 容量/分片 | 任务内基准脚本 | 16/64/128 路里程碑 | 目标网络、媒体和 GPU 硬件必需 |
| 容量/分片 | 任务内基准脚本;本地 16 路入口为 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17` | 默认 16 路软件基线,以及后续 64/128 路分片里程碑 | 本地 16 路控制面可用独立合成源;真实多路、生产 SLA、64/128 路、AI/GPU、网络与存储必须使用目标环境分别验收 |
代码脚手架落地时必须把真实命令同步到本文、`init.ps1`/`init.sh`、`00-ai-start-here.md` 和 `current-state.md`。
+31 -18
View File
@@ -16,16 +16,16 @@ YoVision 使用“通用底座 + 场景包”,按变化频率分为:
| 系统 | 语言/状态 | 职责 | 不负责 |
| --- | --- | --- | --- |
| Sense | Go,有状态 | 设备台账、ONVIF、MediaMTX 控制、对账、探活、隧道、设备型触发、流分片 | AI 判定、事件业务、预警 |
| Sense | Go,有状态 | 设备台账(`modality + capabilities`)、ONVIF 能力探测、MediaMTX 控制、对账、探活、隧道、设备型触发、流分片、配额/Area 策略投影与准入执行、接入运维中心 | AI 判定、事件业务、预警、Tenant/Site/Area/RBAC/全局审计真相 |
| Brain | Python/CUDA,业务无状态 | 解码/推理、检测/姿态/跟踪/ReID、时间窗判定、事件 mapper、像素级触发 | 设备真相源、告警升级、租户权限 |
| Bell | Go + Web,有状态 | 事件校验/存储、规则、预警状态机、投递、反馈、租户/RBAC、审计、配额真相源和管理端 | 媒体转发、模型执行 |
| Bell | Go + Web,有状态 | 事件校验/存储、规则、预警状态机、投递、反馈、Tenant/Site/Area/RBAC、全局审计、配额与 `capture_policy` 真相源和统一管理端 | 媒体转发、模型执行、设备实际态 |
MediaMTX、PostgreSQL、MinIO、Prometheus 等作为独立基础设施部署。
## 3. 部署与系统边界
- 首期每个客户部署一套私有实例,数据和事件证据留在客户环境;数据库实体、RBAC、配置与 API 从第一版携带 `tenant_id` 并保持 SaaS-ready 边界。
- Bell 自研且是事件、Alert、ack、升级链、租户和审计的唯一业务真相源;客户平台通过版本化 OpenAPI/Webhook 集成,不反向接管核心状态机。
- Bell 自研且是事件、Alert、ack、升级链、Tenant/Site/Area/RBAC、配额和全局审计的唯一业务真相源;客户平台通过版本化 OpenAPI/Webhook 集成,不反向接管核心状态机。Sense 只保存执行所需的版本化只读投影,不形成第二份组织/策略真相。
- M1–M3 的视频入口只有 ONVIF/RTSP。现有 NVR 在售前盘点;仅支持 GB/T 28181 的项目必须建立独立适配器任务,不把国标信令混入 Sense 最小骨架。
- M3 客户端为值班室 Web + 响应式移动 H5,可嵌入客户系统;是否开发原生 App 在 M4 后另行决定。
@@ -45,8 +45,8 @@ Sense ── 视频流/触发信号 ──> Brain
主流程:
1. Bell 持有站点配额;Sense 在新增/启用设备时通过版本化内部 API 或只读投影校验。
2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态。
1. Bell 持有站点、Area、配额与 `capture_policy`;首期在同一 PostgreSQL 实例内发布 `bell.site_quota_v1` 和 `bell.area_policy_v1` 两个版本化只读视图。T-009/T-010 已实现 Bell 源表/视图、最小权限和 Sense PostgreSQL repository;Sense 按 Area→Site 的固定 advisory-lock 顺序执行策略与配额准入并记录所用版本。未来分库必须发布新版本契约,不能静默改变 v1 语义。
2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态;PostgreSQL 多实例以数据库时钟短租约和 fencing token 领取 due row,过期 worker 不得提交结果。
3. Brain 消费视频与触发信号,产生符合 v0.1 的事件。
4. Bell 做 schema 与代码级断言,生成平台 ULID,保存不可变事件。
5. 规则命中后创建独立 Alert,先落库再投递,等待 ack 并按策略升级。
@@ -55,7 +55,7 @@ Sense ── 视频流/触发信号 ──> Brain
首个 M3 数据流部署在 S2 民办寄宿学校的 16 路高风险点位,只运行越线、危险区域和聚集等匿名规则,不加载人脸底库。
## 5. 九条不可越界的决定
## 5. 十二条不可越界的决定
1. MediaMTX 独立运行,Sense 管配置与生命周期。
2. 设备型触发源归 Sense;需要解码的像素级触发归 Brain。
@@ -63,9 +63,12 @@ Sense ── 视频流/触发信号 ──> Brain
4. 平台事件 ULID 由 Bell 生成;Brain 只填 `source_event_id`。
5. 一个 PostgreSQL 实例,`sense`/`bell` schema 分离;Brain 无业务 schema。
6. 16/128 都不是单机保证;媒体与推理按独立分片横向扩展。
7. Bell 拥有 `site.max_video_channels`,Sense 在设备写路径执行;不跨 schema 直接写。
7. Bell 拥有 Site/Area、`site.max_video_channels` 与 `capture_policy`,并拥有两个 v1 投影视图;Sense 角色只获得视图 `SELECT`,在设备写路径执行准入,不能写 Bell schema 或读取 Bell 源表。
8. 事件片段写入客户侧 MinIO/S3,常态录像留在客户 NVR;元数据/审计、人脸和训练样本使用独立生命周期。
9. 投递状态机只依赖 Bell provider 接口,不直接依赖某家短信或语音 SDK;生产前至少两条独立路径并能故障切换。
10. 设备领域模型使用 `modality + capabilities`,页面不以摄像头作为唯一根实体;未实现协议适配器明确为 `adapter_not_ready`,不得用模拟遥测伪装交付。
11. Tenant/Site/Area/RBAC、配额、`capture_policy` 与全局审计属于 Bell;Sense Control API v1 只管理 Device 期望态与收敛查询,Sense 只读消费版本化投影并在设备写路径执行,投影不可用时只阻断相关新变更,不静默切断已有链路。
12. Sense 的设备操作审计先写本地持久化 Outbox,再由幂等 relay 异步送入 Bell 全局审计;不得使用“先执行高风险操作、再尽力入队”的顺序。T-010 已冻结脱敏本地事件并实现原子写入;relay 的 transport、签名、确认、重放窗口与留存仍须独立冻结。
## 6. 容量架构
@@ -76,20 +79,29 @@ Sense ── 视频流/触发信号 ──> Brain
- 单分片故障不能扩散到其他分片。
- 管理端默认查看 16 路,但按 128 路设计分页、虚拟列表、筛选和批量操作。
T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、单个生产 MediaMTX 和 16 个独立低码率合成 publisher 完成 `16 → 0 → 16` 批量收敛、四路发布故障隔离/恢复和 `1800.1 s / 180` 样本稳定观察,最大/最终 `unconverged=0`。这只证明默认 16 路的本地软件控制面与拉流基线,不改变上述分片架构:`media_shard.max_streams=32` 仍是待 64/128 路目标环境压测的初始建议,不能从 T-014 推导单机、真实摄像头、AI/GPU、存储或生产 SLA。完整证据见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
## 7. 一致性与失败处理
- PostgreSQL 是期望态真相源;MediaMTX、推理 worker 和对象存储是可对账的实际态。
- 对账器水平触发、幂等、指数退避、限制并发;部分失败不做跨系统回滚,只持续收敛。
- 孤儿删除必须有 10% 安全闸和人工可观察指标。
- 配额读取失败只阻止新增/启用,不中断已有流。
- PostgreSQL `sense` schema 是生产期望态真相源;SQLite 只保留为 M1 本地开发/回归路径。MediaMTX、推理 worker 和对象存储是可对账的实际态。
- 对账器水平触发、幂等、指数退避、限制并发;PostgreSQL 使用 `FOR UPDATE SKIP LOCKED`、每项续租和 fencing token,SQLite 只保留单进程开发语义。部分失败不做跨系统回滚,只持续收敛。
- MediaMTX Path 扫描把“Sense 历史拥有但当前失配”和“从未归属 Sense”分开;未知归属永不自动删除。历史拥有项也只允许在 15 分钟二次快照、1~128 项和 `候选 × 100 <= 当前 Path 总数 × 10` 全部通过时由本地运维命令逐项处置,不提供绕过。
- `bell.site_quota_v1` 行缺失、数值越界、版本回退或读取失败只阻止视频设备新增/启用,不中断已有流;降低配额导致超限时不自动停用,后续准入返回稳定错误并产生运维信号。多 Sense 实例使用 PostgreSQL transaction-scoped advisory lock 串行化同 tenant/site 的计数与写入,不能用进程内锁替代。
- `bell.area_policy_v1` 缺失、非法、版本回退或读取失败时,PostgreSQL repository 拒绝相关新增/启用;`non_imaging_only` 允许非成像设备但拒绝具有 `video_capture` 的设备。已有设备保持原状态,策略冲突由 Bell 管理端显式迁移或取消。同库实时视图不以源记录年龄误判 freshness。
- 设备创建和期望态受理在本地事务内同时写脱敏 `sense.device_operation_outbox`;Outbox 失败回滚业务写入,相同期望态不增加 generation 但仍审计。异步 relay 尚未实现。
- Brain 投递失败落本地队列重试,不阻塞实时推理主链路。
- Alert 先落库再投递,进程重启恢复未完成升级链。
- 值班排班发布前必须按 Site 时区校验班次空档、重叠、联系人停用和通道验证;排班以新版本和未来生效时间发布,不原地改写历史。交接班是进行中 Alert 的显式责任转移事件,不替代排班版本变更。
- 事件证据技术默认保留 30 天并按生命周期删除;客户/法务在 M3 生产上线前确认法规适用性和最终期限,技术默认值不能覆盖其结论。
## 8. 数据与契约
- 核心实体:Tenant → Site → Area/Device → StreamBinding/Zone;Rule → Event → Alert → DeliveryAttempt/Ack。
- Event 与 Alert 不合并:一个事件可触发多次预警与投递,一次预警也可聚合多个事件。
- Bell 核心实体:Tenant → Site → Area(含 `capture_policy`)以及 Role/Binding/Quota/Audit;Sense 核心实体:Device(含 `modality + capabilities`)→ StreamBinding/Zone,以及只记录已观察版本的 SiteQuota/AreaPolicyProjection。两个 schema 以稳定逻辑 ID 关联,不跨 schema 写入;配额 v1 为五列,Area v1 固定为 `tenant_id/site_id/area_id/capture_policy/source_version/source_updated_at` 六列。
- Sense Control API v1 使用站点作用域路径、认证上下文 tenant、HMAC cursor 分页、PostgreSQL 幂等收据与资源 ETag;敏感连接引用只写不读。T-011 已实现 7 个 handler,并以 feature flag 限定到 PostgreSQL 路径;首版外部静态 SHA-256 注册表只实现认证 port 的私有部署适配器。正式签名和兼容规则以 [`contracts/`](contracts/) 为准,Bell 管理服务、JWT/OIDC 与 Outbox relay 仍未实现。
- 业务实体:Rule → Event → Alert → DeliveryAttempt/Ack;Event 与 Alert 不合并。
- Bell 通知域分为三个聚合:Contact/Team 保存身份、成员关系和已验证通道;OnCallSchedule/ScheduleVersion/ShiftException 保存时区、轮换与例外;EscalationPolicy/Step 通过 `person / team / on_call_schedule` 类型化 `target_ref` 引用目标。三者共享逻辑 ID,不复制手机号、班次或轮换字段。
- 每个 DeliveryAttempt 创建时解析当时生效的排班版本,并保存实际收件人、通道、`schedule_version` 和解析时间快照;之后联系人或排班修改不得回写既有投递事实。
- 一个事件可触发多次预警与投递,一次预警也可聚合多个事件。
- 事件 v0.1 以 `raw/contracts/event-v0.1.schema.json` 与 `raw/contracts/README.md` 为准;未知顶层字段拒绝,只允许通过 `ext` 扩展。
- v0.1 还需代码校验时间自洽、`confidence` 当前为 null、证据文件名隐私、唯一 primary sensor 等跨字段约束。
- S2 MVP 不处理人脸。首个人脸试点最早 M5,只允许经法务/客户门禁确认的 S4 成人访客/承包商白名单(≤10,000 人),并提供非人脸替代方式。
@@ -97,21 +109,22 @@ Sense ── 视频流/触发信号 ──> Brain
## 9. 目录目标
```text
Sense/cmd + Sense/internal/{device,onvif,mtx,reconcile,probe,trigger,tunnel,authcb,store}
Sense/cmd + Sense/internal/{device,onvif,mtx,reconcile,orphan,metrics,probe,trigger,tunnel,auth,store}
Brain/{pipeline,models,judge,emit,trigger,contracts}
Bell/cmd + Bell/internal/{ingest,event,rule,alert,deliver,feedback,tenant,audit,store}
Bell/{web,packs,contracts}
deploy/postgres/{001_roles.sql,...,011_privileges_reconcile_safety.sql,tests}
```
当前只有空目录占位;真实脚手架必须由对应任务创建。
Sense 脚手架和 PostgreSQL `001`~`011` 已实现;Brain/Bell 应用目录仍是占位,真实服务必须由对应任务创建。
## 10. 开发顺序
- M0 不写生产代码。
- M1 只动 Sense,5 路接入骨架与 MediaMTX。
- M2 仍以 Sense 为主,完成 16 路开通/停用、对账、多租户投影与隧道。
- M1 只动 Sense,以 1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路接入骨架与 MediaMTX;设备模型从此时起保持模态/能力可扩展,但不提前实现非视频适配器。真实多设备现场门禁移到 T-007,阻塞生产试点但不阻塞本地开发。
- M2 仍以 Sense 为主;Control API、对账、多租户投影和本地 16 路开通/停用基线已完成,隧道等待客户网络条件后补验。
- M3 Brain 与 Bell 同时起步,事件契约首次被真实使用。
- M4/M5 再做 64/128 路分片、完整管理端和多个场景包。
- M4/M5 再做 64/128 路分片、完整管理端和多个场景包;M6 接入雷达、门磁、按钮和可穿戴等非视频适配器。
M3 先执行不少于 2 周的 dry-run,冻结现场标注集,按规则报告召回率和每路每天误报数;现场基线评审后才把数值阈值写入站点验收附件。算法效果指标与系统 SLA 分开验收。
+15 -9
View File
@@ -4,26 +4,31 @@
## M0:兼容性验证与需求定稿
出口:3–5 款摄像头通过 ONVIF 核心操作,采购白名单归档;架构影响型开放问题闭环;M0 临时代码不进入生产基线。
出口:至少一台真实样机完成首期指定型号/固件的 ONVIF/RTSP 准入,指定采购基线归档;架构影响型开放问题闭环;M0 临时代码不进入生产基线。该出口不代表多品牌或批次兼容。
- T-001:建立摄像头兼容性实验矩阵与采购白名单。
- T-001:完成指定摄像头型号准入验证。
- T-002:关闭 Q1/Q2/Q4–Q12 架构影响型需求问题。
## M1:Sense 接入骨架 + MediaMTX
出口:5 路自动建 path、探活、断线重建;使用 MediaMTX 生产数据面,完整 MiBeeNvr 不替代生产基线。
出口:1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路自动建 path、探活、断线重建;使用 MediaMTX 生产数据面,完整 MiBeeNvr 不替代生产基线。该出口是实验室软件闭环,不代表真实五机或生产 SLA。
- T-003:建立 Sense Go 脚手架、设备台账、ONVIF 与 MediaMTX 薄客户端。
- 后续按 T-003 的基线拆分对账、探活、5 路集成与部署任务。
- T-003:建立无需真实摄像头即可验证的 Sense Go 脚手架、设备台账、ONVIF port/fake、MediaMTX 生成客户端与薄封装;完成不代表 M0/M1 出口。
- T-003 只冻结 `/healthz`、`/readyz` 运维探针,设备管理公共 API 留给后续契约任务;真实 ONVIF adapter 不用 fake 冒充。
- T-006:在 T-001 指定准入基线与 T-003 骨架之上,以 1 路海康实机 + 至少 4 路可独立启停的合成 RTSP 源完成五路自动建 path、探活、恢复和 MediaMTX 实验室集成验收。
- T-001 与 T-003 已完成;T-006 可领取实施,只需准备至少 4 条可独立启停的合成源,不要求当前购买更多摄像头。
## M2:对账、多租户投影与 16 路全流程
出口:10 个站点试点,至少一个站点完成 16 路开通/停用;`unconverged = 0` 稳定。
- 对账器幂等/退避/并发/10% 安全闸。
- Bell 站点配额投影到 Sense 的版本化读取契约。
- WireGuard 边缘隧道与断网恢复。
- 16 路批量开通、停用和容量基准。
- T-008:冻结 Sense Control API v1 与 Bell `site_quota_v1` 只读投影契约;只建立接口和测试门禁,不等于 handler、Bell 表或 migration 已实现。
- T-009:使用 PostgreSQL 17.10 建立 `sense`/`bell` schema、Bell 配额源表/只读视图、最小权限和 Sense PostgreSQL repository;SQLite 保留为 M1 本地路径。
- T-010:建立 Bell Area/`capture_policy` 只读投影、Sense 成像准入与设备操作审计 Outbox 原子写入;relay 传输协议留给后续任务。
- T-011:按 T-008 契约实现认证 tenant 上下文、7 个设备/operation handler、PostgreSQL 幂等收据、ETag/HMAC cursor、最多 128 项批量操作和停用精确 path 收敛;公共控制 API 默认关闭且只在 PostgreSQL 路径启用。
- T-012:以 PostgreSQL 数据库时钟租约和 fencing token 防止多实例重复调和;增加可证明的 Path 历史归属、默认只读孤儿报告、15 分钟二次确认、10% 不可绕过安全闸和低基数 `/metrics`。
- T-013(后置,尚未创建):WireGuard 边缘隧道与断网恢复;等待客户网络拓扑、地址规划和部署权限,不阻塞本地软件开发。
- T-014:使用隔离 PostgreSQL、真实 Control API 和 16 个独立本地合成 publisher,完成批量开通/停用、局部故障恢复和资源观测;正式基线为 `1800.1 s / 180` 样本、最大/最终 `unconverged=0`,仍只形成实验室软件证据,不替代 T-007/T-013 或生产 SLA。
## M3:首个 16 路端到端 MVP
@@ -34,6 +39,7 @@
- 规则引擎、场景包加载、预警状态机与双路径投递。
- 最小 Web/App 处置流程、RBAC 与审计。
- 现场误报基线和反馈队列。
- T-007:客户试点、借用或租赁设备条件具备后完成至少 5 条独立真实上游的现场验收;不阻塞本地开发,但阻塞生产试点启用和真实多路 SLA。
## M4:64 路分片与完整管理系统
+62 -14
View File
@@ -2,28 +2,28 @@
> 用户故事用于连接需求、页面交互和任务验收。当前 M0/M1 以设备与实施流程为主,最终用户界面在 M3/M4 才实现。
## US-001 批量开通摄像头
## US-001 批量开通视频设备
- 角色:实施工程师。
- 目标:一次导入并验证一个默认 16 路站点,不逐路手工配置。
- 目标:一次导入并验证一个默认 16 路视频站点,不逐路手工配置;操作入口位于通用设备台账的视频模态筛选下。
- 价值:降低交付时间并为 128 路扩展保留操作效率。
- 验收:支持待激活、逐设备结果、失败可重试、超配额明确拒绝;128 路规模下仍使用分页/批量流程。
- 关联:RQ-C-01~RQ-C-08,IX-001~IX-003。
- 验收:导入后先进入待激活,任务历史可查看逐设备成功/失败原因并仅重试失败项;认证失败时提供不回显旧密码的凭据更新路径;超配额明确拒绝;128 路规模下仍使用分页/批量流程。
- 关联:RQ-C-01~RQ-C-08,IX-001~IX-004、IX-015。
## US-002 查看设备与流健康
- 角色:平台运维/站点管理员。
- 目标:快速知道哪些设备离线、时间漂移、流未收敛或分片异常。
- 目标:快速知道哪些设备离线、认证失败、时间漂移、流未收敛、隧道异常或分片异常,并能区分期望态与各下游实际态。
- 价值:在业务预警受影响前定位故障。
- 验收:设备期望态与实际态分开显示;断线重连可观察;业务预警和运维告警不混用。
- 关联:RQ-C-06~RQ-C-08、RQ-C-29、RQ-C-34,IX-004。
- 验收:设备期望态与媒体/推理/存储实际态分开显示;对账差异、退避、下次重试和孤儿安全闸可观察;边缘隧道与视频数据面分别显示;业务预警和运维告警不混用。
- 关联:RQ-C-06~RQ-C-08、RQ-C-29、RQ-C-34,IX-004、IX-019。
## US-003 处置业务预警
- 角色:家属/值班员。
- 目标:收到异常后查看关联证据、确认接手并记录处置结果。
- 价值:把“发出通知”变成“有人负责”。
- 验收:首次投递、送达、看到、ack、升级分别可追溯;无 ack 自动升级;进程重启不丢失升级链。
- 验收:通道的已发出、已送达、已看到,与 Alert 的 ack、升级分别可追溯;无回执、通道不支持、可重试失败和最终失败不伪装成功;Alert 可查看聚合的 Event,Event 可查看触发的 Alert;并发 ack 时后到者看到真实处置人且不能覆盖;无 ack 自动升级,进程重启不丢失升级链。
- 关联:RQ-C-17~RQ-C-30,IX-005~IX-008。
## US-004 标记误报
@@ -37,9 +37,9 @@
## US-005 配置规则与升级链
- 角色:租户管理员/站点管理员。
- 目标:按站点和设备覆盖场景模板,配置区域、时段、持续时间和联系人升级链。
- 目标:按站点和设备覆盖场景模板,配置区域、时段、持续时间和升级链;升级步骤引用指定人员、值班组或值班排班,不直接写死手机号。
- 价值:同一通用底座适配不同场景。
- 验收:继承来源清楚、变更可试运行/回滚、静默不超过 4 小时且自动恢复。
- 验收:继承来源清楚、变更可试运行;升级目标可预览当前解析人和通道,投递后保留解析快照;回滚先展示版本差异与影响,并创建新的不可变版本,不改写历史;静默不超过 4 小时且自动恢复。
- 关联:RQ-C-11~RQ-C-15、RQ-C-23~RQ-C-28,IX-010~IX-012。
## US-006 事件最小权限查看
@@ -50,14 +50,62 @@
- 验收:越权返回统一拒绝;无授权租户看不到人脸能力;常态录像不因事件页面被间接暴露。
- 关联:RQ-S1-05~RQ-S1-07、RQ-S2-08、RQ-C-31~RQ-C-33,IX-013。
## US-007 兼容性实验记录
## US-007 指定型号准入记录
- 角色:M0 测试/实施工程师。
- 目标:对候选摄像头执行一致的 ONVIF 与恢复测试并形成采购白名单。
- 价值:在写生产接入代码前先识别厂商差异。
- 验收:3–5 款设备的型号、固件、认证方式、Profiles/StreamUri/校时、主子码流、掉线恢复都有证据;不记录密码和真实客户信息。
- 目标:对首期指定摄像头型号/固件执行一致的 ONVIF 与恢复测试并形成采购准入基线。
- 价值:在生产接入前确认统一采购基线可用,同时控制首期多品牌适配成本。
- 验收:至少一台真实样机的厂商、型号、硬件版本、固件、认证方式、Profiles/StreamUri/校时、主子码流和 3 次掉线恢复都有证据;明确单样本和非多品牌限制,不记录密码和真实客户信息。
- 关联:M0,T-001;无产品 UI,使用版本化测试文档。
## US-008 管理异构设备与隐私准入
- 角色:实施工程师/站点管理员。
- 目标:在统一设备台账中按模态和能力添加、筛选与查看视频、雷达、门磁、按钮、可穿戴等设备;隐私区域只允许非成像设备。
- 价值:M2 固化不会因 M6 异构设备接入而推倒重来的信息架构,同时把隐私要求落实为系统准入约束。
- 验收:一级入口使用“设备”;添加时先选模态,详情按探测能力显示页面并标明来源、时间与生效差异;未实现适配器显示 `adapter_not_ready`,不得伪装在线;`non_imaging_only` Area 不允许新增或启用成像设备,策略读取失败时拒绝新变更并给出恢复路径;M2 完成信息架构与视频准入,M6 完成非视频适配器。
- 关联:RQ-S1-05、NFR-CMP-05,IX-014、IX-016、IX-019。
## US-009 以最小权限运维接入系统
- 角色:平台运维/租户管理员/站点管理员/只读用户。
- 目标:进入 Sense 时明确当前租户、站点、账号和角色,并只看到权限范围内的数据与操作。
- 价值:让私有化单租户交付也保留 SaaS-ready 隔离边界,避免原型把管理员权限当成默认事实。
- 验收:切换站点会刷新上下文;只读角色看不到写操作;深链越权安全拒绝且不泄露设备存在性;设备操作日志可带筛选上下文跳到 Bell 全局审计。
- 关联:RQ-C-31~RQ-C-33,IX-013、IX-020。
## US-010 管理站点与区域准入策略
- 角色:租户管理员/站点管理员。
- 目标:在统一管理端维护 Site、Area 层级与 `capture_policy`,并让 Sense 使用同一版本的策略执行设备准入。
- 价值:避免 Area 在 Sense、Bell 各有一份真相,保证权限、报表和隐私准入口径一致。
- 验收:Bell 是 Site/Area/配额/策略真相源;策略变更影响已有成像设备时必须显式选择迁移或取消,不自动停用;Sense 展示策略版本与同步状态,投影不可用时仅拒绝相关新写入。
- 关联:RQ-S1-05、NFR-CMP-05,IX-016、IX-020。
## US-011 完成值班交接
- 角色:交班值班员/接班值班员。
- 目标:在班次结束前把所有未 ack、处置中和升级中的 Alert 连同备注交给下一班,并得到明确接手确认。
- 价值:保证“有人负责”不在班次边界中断。
- 验收:交接清单显示当前状态、处置人与下一次升级时间;接班人确认后写审计;交接过程不暂停、不重置升级链,未确认交接时原责任关系保持不变。
- 关联:RQ-C-17~RQ-C-30,IX-021。
## US-012 导出规则验收效果
- 角色:项目负责人/租户管理员/验收人员。
- 目标:按规则版本和冻结样本窗口查看可复核的召回率与每路每天误报数。
- 价值:让站点验收使用场景相关、可追溯的效果口径,避免跨场景“准确率”误导。
- 验收:报表展示规则版本、样本时间窗、正样本/漏检样本、通道天数、召回率和每路每天误报数;明确不提供跨场景统一准确率;导出保留筛选与口径说明。
- 关联:RQ-C-22、`02-requirements` §8,IX-022。
## US-013 管理联系人和值班排班
- 角色:租户管理员/站点管理员。
- 目标:使用同一套人员、值班组和通知通道主数据配置可发布的值班排班,并让升级策略引用排班目标。
- 价值:避免先把联系人和号码写死进升级链、后续再迁移到时段轮换;同时避免把联系人、班次和轮换混成一个难维护对象。
- 验收:联系人维护身份、角色、值班组和已验证通道,不出现排班轮换字段;排班维护时区、班次、周轮换、生效日期、临时替班和版本;发布前发现空档/重叠并阻止发布,可预览当前及未来值班人;投递固化收件人、通道与排班版本快照;交接进行中 Alert 不静默修改未来排班。
- 关联:RQ-C-24~RQ-C-27、RQ-C-33,IX-012、IX-023。
## 追溯规则
新增 P0 UI 任务必须引用至少一个 US 和一个 IX;若没有 UI,任务文件明确写“不适用”。需求变化先更新用户故事和交互清单,再改页面。
+23 -10
View File
@@ -4,19 +4,29 @@
| ID | 场景 | 必须覆盖的状态与行为 | 关联 US | 阶段 |
| --- | --- | --- | --- | --- |
| IX-001 | 批量导入设备 | 下载模板、上传校验、逐行错误、重复序列号、待激活、确认写入 | US-001 | M2 |
| IX-002 | 配额与批量结果 | 显示已用/上限;超 16 不等于非法,按站点配置校验;部分成功可重试 | US-001 | M2 |
| IX-001 | 批量导入设备 | 下载模板、上传校验、逐行错误、重复序列号、待激活、确认写入;任务历史保留逐项结果、操作者与时间,支持仅重试失败项 | US-001 | M2 |
| IX-002 | 配额与批量结果 | 显示已用/上限;超 16 不等于非法,按站点配置校验;部分成功可重试;配额不可读取时只禁用新增/启用并保留已有列表与流 | US-001 | M2 |
| IX-003 | 设备列表 | 分页/筛选/批量选择;128 路不一次加载所有视频和详情 | US-001 | M2/M4 |
| IX-004 | 设备健康 | 期望态、实际态、最后在线、时间漂移、分片、重试进度;运维告警独立 | US-002 | M2/M4 |
| IX-005 | 预警到达 | 明确严重度、站点、时间、证据可用性;重复投递不产生重复处置 | US-003 | M3 |
| IX-006 | ack | 一次点击可确认,显示确认人/时间;并发 ack 有清晰结果 | US-003 | M3 |
| IX-007 | 升级 | 显示当前层级、下一次升级时间、每次投递状态;失败不能伪装成功 | US-003 | M3 |
| IX-008 | 事件详情 | 结构化事实、抓拍、视频、时间线、处置;证据加载失败可重试且不丢元数据 | US-003 | M3/M4 |
| IX-004 | 设备健康 | 期望态、媒体/推理/存储实际态、认证失败、最后在线完整时间与站点时区、时间漂移、分片、重试进度;运维告警独立,凭据更新只允许写入新值且旧值永不回显 | US-002 | M2/M4 |
| IX-005 | 预警到达 | 明确严重度、站点、时间、证据可用性与关联事件数;重复投递不产生重复处置 | US-003 | M3 |
| IX-006 | ack | 一次点击可确认,显示确认人/时间;服务端首个成功者成为处置人,并发后到者看到当前处置人和冲突结果,不静默覆盖或显示双成功 | US-003 | M3 |
| IX-007 | 升级与投递 | 显示当前层级、下一次升级时间;每个通道分别展示已发出、已送达、已看到,Alert ack 单独展示;无回执/不支持/未知、可重试失败及最终失败不得伪装成功 | US-003 | M3 |
| IX-008 | 事件详情 | 结构化事实、抓拍、视频、时间线、处置;证据加载失败可重试且不丢元数据;Alert 展示关联 Event 与聚合原因,Event 展示触发的 Alert 与最终状态,两边可深链;被抑制且未创建 Alert 的事件明确标为未触发 | US-003 | M3/M4 |
| IX-009 | 误报反馈 | 确认动作、可选原因、提交成功反馈;只改变 outcome,不改原始事件 | US-004 | M3 |
| IX-010 | 规则编辑 | 显示租户/站点/设备继承来源,支持区域/警戒线/时段/持续时间 | US-005 | M3/M4 |
| IX-011 | 规则试运行 | 明确“未正式生效”,展示命中样本与影响范围,支持取消/回滚 | US-005 | M4 |
| IX-012 | 升级链/静默 | 联系人顺序、超时、双通道;静默最长 4h、显示自动恢复时间、无永久选项 | US-005 | M3/M4 |
| IX-011 | 规则试运行与回滚 | 明确“未正式生效”,展示命中样本与影响范围,支持取消;回滚前展示目标版本差异与影响,确认后创建并发布等价的新版本,历史版本保持不可变;版本冲突时阻止覆盖并提供刷新/另存路径 | US-005 | M4 |
| IX-012 | 升级链/静默 | 升级步骤通过类型化目标引用指定人员、值班组或值班排班,不写死号码;显示目标解析预览、联系人顺序、超时与双通道;每次投递固化实际收件人、通道和排班版本快照;静默最长 4h、显示自动恢复时间、无永久选项 | US-005、US-013 | M3/M4 |
| IX-013 | 权限与隐私 | 越权统一处理;无授权租户不展示人脸入口;不泄露流 URL/凭据 | US-006 | M3/M4 |
| IX-014 | 设备模态与能力 | 一级入口为“设备”;列表可按 `modality` 筛选,添加时先选模态,详情按 `capabilities` 渐进展示;能力同时显示厂商/型号/固件/认证、探测来源与时间、探测值和生效值差异;视频设备显示画面/媒体/检测区域,未交付的非视频适配器显示 `adapter_not_ready`,不用虚构遥测或禁用的视频页签占位 | US-008 | M2/M6 |
| IX-015 | 停用与收敛 | 明确区分暂停推理订阅、停用设备接入和仅踢当前会话;暂停推理必须说明“解除推理侧订阅”,并在结果中显示推理订阅、其他 reader 数与上游按需拉流状态;操作前展示对观看、录制与证据回捞的影响并二次确认;期望态立即改变、实际态经对账收敛,部分失败可逐项重试;重复请求不产生重复副作用,但每次请求均留审计结果 | US-001、US-002 | M2 |
| IX-016 | 隐私区域设备准入 | Area 与 `capture_policy` 由 Bell 统一管理,Sense 消费带版本的只读投影;`non_imaging_only` 不允许新增/启用具有成像能力的设备;前端就地禁用并解释,后端返回稳定错误码;策略读取失败时拒绝新变更并告警,已有设备遇到策略变化时由 Bell 进入显式迁移/取消流程,不静默留存或停用 | US-008、US-010 | M2/M6 |
| IX-017 | 检测区域几何编辑 | 多边形和方向警戒线支持绘制、显式完成、撤销、清空、顶点编辑;警戒线在草稿/已保存态均显示方向箭头并可反转;鼠标与键盘坐标操作等效;画面参数变化后标记待校准且不自动改坐标;区域版本与规则版本独立 | US-005 | M3 |
| IX-018 | 草稿与跨系统上下文 | 几何草稿自动保存并可恢复;只有持久化失败或切换空间类型等会破坏草稿的动作才拦截确认;Sense↔Bell 双向携带并消费 `camera + zone + return_to` 上下文;返回后恢复草稿,版本已被他人更新时提示冲突且不覆盖 | US-005 | M3 |
| IX-019 | 运维中心与写入降级 | Sense 用一个一级“运维中心”聚合对账差异/退避/下次重试/孤儿安全闸、媒体与推理分片、边缘节点/隧道/补传、运维告警和系统状态;配额或 Area 策略不可读取时页面继续可读且已有链路不受影响,只禁用相关写操作;不得混入 Bell 业务预警 | US-002、US-008 | M2/M4/M6 |
| IX-020 | 管理上下文、RBAC 与审计归属 | 全局头部显示租户、站点、账号和角色;只读角色隐藏写操作,深链越权统一拒绝且不泄露资源是否存在;Tenant/Site/Area/RBAC/配额/全局审计由 Bell 管理,Sense 只呈现消费上下文和设备操作入口,携带 tenant/site/device/return_to 深链到 Bell 审计;Bell 移动端底部导航不超过 5 项,并通过可发现的“管理”入口访问 Site/Area 与审计 | US-006、US-009、US-010 | M2/M4 |
| IX-021 | 值班交接 | 清单覆盖未 ack、处置中和升级中的 Alert,显示交班人、接班人、备注及下一次升级时间;接班确认写审计,交接期间不暂停或重置升级链,未确认时不转移责任 | US-011 | M3 |
| IX-022 | 规则验收报表 | 按规则版本与冻结样本窗口展示召回率、每路每天误报数和计算样本量;支持保留口径的导出;不提供跨场景统一准确率 | US-012 | M4 |
| IX-023 | 联系人与值班排班 | 在“升级链”内部以升级策略、值班与排班、联系人和通道三个二级模块统一设计;联系人与排班共享人员/值班组/已验证通道主数据但分对象维护;排班覆盖站点时区、周轮换、生效日期、临时替班、空档/重叠冲突、当前/未来值班人预览和版本发布审计;交接班只转移进行中 Alert,不暗改未来排班 | US-005、US-011、US-013 | M3/M4 |
## 全局状态
@@ -44,7 +54,10 @@
## 无障碍与安全
- 键盘可完成主要 Web 流程,焦点清晰,表单错误关联到字段。
- 文本和关键状态满足可读对比度;严重度同时用文字/图标表达。
- 正文、按钮文字和关键状态对比度不低于 4.5:1;大字号(不低于 24px,或不低于 18.66px 且粗体)不低于 3:1;非文本控件、状态边界与焦点指示器不低于 3:1。严重度同时用文字/图标表达。
- 当前导航项显式使用 `aria-current="page"`;响应式隐藏可见文字时,图标按钮仍保留稳定的可访问名。
- 使用 tab 模式时完整实现 `tablist` / `tab` / `tabpanel`、`aria-controls` / `aria-labelledby`、单一 Tab 停靠点和方向键导航;不能完整实现时不声明 tab 角色。
- 移动端主要操作目标不小于 44×44px,输入控件正文不小于 16px;绘制画布必须提供无需精确点击的键盘/表单等效路径。
- 删除、停用、踢流、批量覆盖和规则正式发布需要明确影响范围与二次确认。
- UI 不展示摄像头密码、完整连接串、token 或可复用的内部流地址。
+52 -7
View File
@@ -1,6 +1,6 @@
# API 与契约
> 事件契约 v0.1 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。
> Brain → Bell 事件契约 v0.1、Sense Control API v1、Bell 配额/Area 只读投影 v1 与 Sense 本地设备审计事件 v1/v2 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。
## 1. 已冻结:Brain → Bell 事件契约
@@ -17,17 +17,37 @@
- 证据文件名只含事件 ID 与日期目录,不含 IP、端口、凭据或客户名。
- `sensors` 中恰有一个 primary,且其 `device_id` 与顶层一致。
## 2. 待冻结的内部接口
## 2. 跨系统接口状态
| 调用方 → 提供方 | 用途 | 当前约束 | 状态 |
| --- | --- | --- | --- |
| Sense → Bell | 读取站点视频配额 | 版本化;默认 16、最大 128;失败时拒绝新增/启用但不影响已有流 | 待 M2 设计 |
| Sense → Bell | 读取站点视频配额 | 同一 PostgreSQL 实例内只读 `bell.site_quota_v1`;默认 16、最大 128;失败时拒绝新增/启用但不影响已有流 | T-008 冻结,T-009 已实现数据路径 |
| Sense → Bell | 读取 Area 成像准入 | 只读 `bell.area_policy_v1`;`video_allowed | non_imaging_only`;缺失/非法/回退失败关闭但不影响已有设备 | T-010 已冻结并实现数据路径 |
| Sense → Bell | 汇入设备操作审计 | 本地 Outbox 事件已冻结并原子落库;transport、签名、确认与留存未冻结 | T-010 本地基础已实现,relay 待设计 |
| Bell → Sense | 请求事件证据/pre-roll 切片 | 幂等、按租户授权、异步结果、不得暴露原始凭据 | 待 M3 设计 |
| Bell → Brain | outcome/误报反馈 | 原事件不可变;反馈可重试、去重、审计 | 待 M3 设计 |
| Sense → Brain | 流绑定与设备型触发 | 分片可路由,触发入口与流控制解耦 | 待 M2/M3 设计 |
| Worker → 控制面 | 注册、心跳、容量 | `max_sources` 来自 profile/压测,不固定为 16 | 待 M3 设计 |
## 3. 待冻结的 Bell 公共 API
冻结签名和失败语义见 [`contracts/README.md`](contracts/README.md)、[`contracts/site-quota-v1.sql`](contracts/site-quota-v1.sql)、[`contracts/area-policy-v1.sql`](contracts/area-policy-v1.sql) 与 [`contracts/sense-device-audit-v1.schema.json`](contracts/sense-device-audit-v1.schema.json)。Bell 拥有投影源数据和视图,Sense 数据库角色只有 `SELECT`;未来分库必须发布新版本,不能在 v1 下把本地视图静默替换为网络调用。
## 3. 已冻结:Sense Control API v1
- OpenAPI:[`contracts/sense-control-v1.openapi.json`](contracts/sense-control-v1.openapi.json)
- 语义、资源所有权、幂等、并发与兼容规则:[`contracts/README.md`](contracts/README.md)
- 范围:设备分页查询、创建、读取、修改、单项启停、最多 128 项的批量启停和批量操作查询。
关键规则:
- 所有业务路径使用 `/api/v1` 和 Bearer 认证;tenant 只来自认证上下文,跨租户访问与不存在统一为 `404 not_found`。
- Site、Area、RBAC、配额和全局审计仍由 Bell 持有;Sense 只管理 Device 期望态和收敛状态,不提供这些 Bell 资源的 CRUD。
- 列表使用稳定顺序和不透明 cursor,默认 50、最大 100;创建与批量写要求 `Idempotency-Key`,资源修改与单项期望态写要求 `If-Match`。
- `endpoint_ref`、`credential_ref`、`profile_token` 只写不读;设备 ID 由服务端生成。普通响应和错误不得包含凭据、完整流 URI、token 或 MediaMTX 内部配置。
- v1 不提供删除设备;停用设备保留历史。写入受理只表示期望态已持久化,不能表示实际态已收敛。
T-008 冻结公共控制契约;T-009/T-010 建立 PostgreSQL 投影、准入和本地审计基础;T-011 已实现 7 个 HTTP handler、外部静态摘要认证适配器、tenant/Site scope、幂等收据、ETag/HMAC cursor 和持久化 batch operation。业务路由默认关闭且仅可在 PostgreSQL 上开启;Bell 管理服务、JWT/OIDC 和 Outbox relay 尚未实现。
## 4. 待冻结的 Bell 公共 API
资源范围预计包括:租户、站点、设备只读投影、规则、事件、预警、ack、处置、误报反馈、审计和报表。设计时必须满足:
@@ -38,15 +58,40 @@
- 错误体包含稳定错误码、可读消息和 trace ID,不返回内部堆栈或凭据。
- 人脸功能未授权时表现为能力不存在,而非仅按钮置灰。
## 4. MediaMTX 接口边界
## 5. MediaMTX 接口边界
Sense 使用 MediaMTX 官方 OpenAPI 生成客户端并加薄封装。业务代码不得散落硬编码 path API;生成代码不可手改。MediaMTX path 不是租户/站点/设备的业务真相源。
## 5. 变更流程
### 5.1 T-003 已实现的内部适配契约
以下是 Sense 内部 Go port,不是 Bell 或第三方可依赖的公共 HTTP API:
| Port | 操作 | 数据所有者 / 失败语义 |
| --- | --- | --- |
| ONVIF adapter | `Probe(target)`、`SetSystemDateAndTime(target, time)` | 设备是外部来源;`target` 只含 endpoint ref 与不透明 credential ref。错误稳定映射为认证失败、超时、不可用、响应无效,不记录凭据或完整流地址 |
| MediaMTX paths | `CreatePath`、`GetPath`、`EnsurePath`、`DeletePath`、`PathReady`、`ListPathNames` | 设备台账持有期望态,MediaMTX 只持有运行配置;分页枚举只返回名称、不返回 source。普通调和只操作精确台账 Path;独立孤儿流程只报告未知归属,受控处置只接受有历史归属的 stale Path |
| Device repository | 设备、期望态、实际态、调和进度 | SQLite 是 M1 默认开发路径;PostgreSQL 是 M2 生产路径并只读 Site/Area 两个 Bell 视图。调和退避持久化;配额/Area 失败时拒绝相关新增或启用,不关闭已有流;高风险写入与脱敏 Outbox 同事务 |
MediaMTX 薄封装调用同版官方 OpenAPI 的 `/v3/config/paths/get|add|patch|delete/{name}`、`/v3/config/paths/list` 与 `/v3/paths/get/{name}`。列表有最大页数和重复页保护,source URI 在薄封装内丢弃。生成源、版本和 SHA-256 见 `docs/03-tech-stack.md`;业务包不得直接 import 生成包。
### 5.2 设备台账语义
- 设备类型由 `modality` 表达物理类别,由多值 `capabilities` 表达视频采集、音频、空间规则或遥测能力,避免把“摄像头”固化为唯一设备模型。
- 视频配额只统计 `desired_state=enabled` 且具有 `video_capture` capability 的设备;站点默认 16、可配置 1~128。禁用设备和非视频传感器不占视频路数。
- SQLite 表使用 `sense_` 前缀且只是 M1 实验室兼容路径;PostgreSQL 使用 `sense.devices`、能力/调和表、两个投影观察表和 `sense.device_operation_outbox`。PostgreSQL 不建立可写 Site/Area 真相副本;后续公共控制 API 只在 PostgreSQL 路径启用,不能把 SQLite 描述为 Area/Outbox 生产等价实现。
- 摄像头密码不进入设备普通字段。`credential_ref` 只保存外部密钥引用;ONVIF 返回的 stream URI 只在内存中传给 MediaMTX,不写入设备台账或日志。
### 5.3 Sense 进程 HTTP 面
`GET /healthz` 表示进程存活,`GET /readyz` 表示所选数据库已打开且 schema/权限前置检查完成;两者不要求认证,也不等价于摄像头、MediaMTX path 或里程碑健康。`GET /metrics` 默认输出无租户/设备/Path 标签的 Prometheus 汇总,可通过 `SENSE_METRICS_ENABLED=false` 关闭。T-011 在 `SENSE_CONTROL_API_ENABLED=true`、PostgreSQL v5 schema 和外部安全文件全部有效时注册冻结的 `/api/v1` 路由;默认 SQLite 不注册业务路由。业务路由不提供无 Site 边界的 `/api/v1/devices` 临时接口。
孤儿报告/处置不是公共 HTTP API。它由 PostgreSQL 专用 `cmd/sense-orphan` 在受控运维主机执行,使用 15 分钟 scan ID、actor、精确确认文本和运行前二次快照;不改变 Sense Control API v1 的 7 个 endpoint。
## 6. 变更流程
1. 在对应任务文件写清调用方、提供方、数据所有者、失败语义、幂等与兼容策略。
2. 更新本文和 schema/OpenAPI。
3. 同步生产者、消费者、契约测试和示例。
4. 记录迁移、回滚与版本废弃策略。
事件 v0.1 的破坏性变化必须发布新版本,不能原地修改已被 M3 生产者/消费者使用的契约。
事件 v0.1、Sense Control API v1、配额/Area 投影 v1 或本地设备审计 v1 的破坏性变化必须发布新版本,不能原地修改已被生产者/消费者使用的契约。
+80
View File
@@ -0,0 +1,80 @@
# Sense 控制面、准入投影与本地审计契约 v1
> 冻结日期:2026-08-07。Control API 契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额与全局审计的所有者。T-011 已实现 Control API handler 与 PostgreSQL 一致性边界;Bell 管理服务、JWT/OIDC 和 Outbox relay 仍未实现。
## 契约文件
| 文件 | 生产者 / 所有者 | 消费者 | 用途 |
| --- | --- | --- | --- |
| [`sense-control-v1.openapi.json`](sense-control-v1.openapi.json) | Sense | Bell 管理面、受控集成方 | 设备查询、创建、修改、启停与批量操作 |
| [`site-quota-v1.sql`](site-quota-v1.sql) | Bell | Sense | 单 PostgreSQL 实例内的站点视频配额只读投影 |
| [`area-policy-v1.sql`](area-policy-v1.sql) | Bell | Sense | Area 归属与 `capture_policy` 只读投影 |
| [`sense-device-audit-v1.schema.json`](sense-device-audit-v1.schema.json) | Sense | 本地 Outbox;未来 Bell relay | 脱敏设备操作审计事实,不包含传输协议 |
| [`sense-device-audit-v2.schema.json`](sense-device-audit-v2.schema.json) | Sense | 本地 Outbox;未来 Bell relay | v1 后继,增加脱敏配置修改受理事实;v1 文件保持不变 |
OpenAPI 的 `/api/v1` 路径是公共控制面边界;`/healthz`、`/readyz` 仍是非业务运维探针。v1 不提供设备删除:停用设备使用期望态接口,保留设备、操作和审计历史。Site、Area、配额、RBAC 和审计聚合不由 Sense 提供 CRUD。
## HTTP 资源与操作
| 操作 | 路径 | 关键约束 |
| --- | --- | --- |
| 列出 / 创建设备 | `GET/POST /api/v1/sites/{site_id}/devices` | 列表使用不透明 cursor,默认 50、最大 100;创建要求 `Idempotency-Key`,设备 ID 由服务端生成 |
| 读取 / 修改设备 | `GET/PATCH /api/v1/sites/{site_id}/devices/{device_id}` | 修改要求 `If-Match`,版本不匹配返回 `412 etag_mismatch` |
| 修改期望态 | `PUT /api/v1/sites/{site_id}/devices/{device_id}/desired-state` | 要求 `If-Match`;受理不表示实际态已经收敛 |
| 批量修改期望态 | `POST /api/v1/sites/{site_id}/devices:batchDesiredState` | 要求 `Idempotency-Key`,最多 128 项,异步返回逐项结果 |
| 查询批量操作 | `GET /api/v1/operations/{operation_id}` | 只返回当前租户和站点可见的操作 |
列表按 `created_at ASC, id ASC` 稳定排序,cursor 是服务端生成的不透明位置标记。客户端不得解析或拼接 cursor;服务端可以在兼容范围内改变编码。批量不是跨设备全有或全无事务:每项独立接受或拒绝,成功项继续收敛,失败项带稳定错误码。请求内重复 `device_id` 视为对应项 `invalid_request`,不得用“最后一项覆盖”。重试失败项应使用新幂等键;原样重放整个请求必须返回原收据。
## 身份、租户与敏感信息
- 所有 `/api/v1` 操作都需要 Bearer 认证;具体 token 格式由认证任务冻结。`tenant_id` 只从认证上下文取得,body、query 和 path 均不能自报 tenant。
- 跨租户访问与资源不存在都返回 `404 not_found`,不得用状态码、消息或耗时泄露资源是否存在。授权范围不足但不涉及资源枚举时返回 `403 forbidden`。
- `endpoint_ref`、`credential_ref` 和 `profile_token` 是 write-only 输入。不允许 userinfo 形式的完整 RTSP URI;响应、错误、日志与示例不得包含这些引用、密码、token、完整连接串或 MediaMTX 内部配置。
- 创建设备显式提交 `modality + capabilities`;`video` 模态必须包含 `video_capture`,任何请求了 `video_capture` 的设备都必须同时提供 endpoint 与 credential 引用。服务端仍须由适配器验证能力,不能把客户端声明当作探测成功。
- `ETag` 表示设备资源版本;`If-Match` 缺失返回 `428 precondition_required`,过期版本返回 `412 etag_mismatch`。相同期望态重复提交不增加 generation,但每次受理仍可产生审计记录。
`Idempotency-Key` 的作用域是“认证主体 + tenant + site + operation + key”,服务端至少保存 24 小时。同作用域、同请求体重放返回首次状态码和响应;同 key 不同请求体返回 `409 idempotency_conflict`。幂等收据不等价于实际态完成。
## 配额投影与准入
Bell migration 最终创建 `bell.site_quota_v1`,列顺序和含义固定如下:
| 列 | 含义 |
| --- | --- |
| `tenant_id`、`site_id` | 与两个 schema 共享的稳定逻辑 ID |
| `max_video_channels` | 默认 16,有效范围 1~128 |
| `source_version` | 站点投影每次变更后单调递增的版本 |
| `source_updated_at` | Bell 源记录的更新时间 |
视图由 `bell_app` 拥有;`sense_app` 只有 Bell schema 的 `USAGE` 和该视图的 `SELECT`,没有 `INSERT`、`UPDATE`、`DELETE` 或 Bell 源表权限。Sense 不得通过任何旁路写 Bell schema。首期使用同一 PostgreSQL 实例及 `sense`/`bell` schema;如果未来分库,必须发布新版本的网络契约,不能在 v1 下静默改变一致性和失败语义。
配额只统计 `desired_state=enabled` 且 capabilities 含 `video_capture` 的设备。创建已启用视频设备或把视频设备启用时,Sense 必须在同一设备写路径读取并记录所用 `source_version`,同时验证 Area 策略投影。降低配额不会自动停用已有设备;若当前占用已超限,后续创建/启用返回 `409 quota_exceeded`。配额行缺失、越界、版本回退或投影不可读时返回 `503 quota_projection_unavailable`,只阻止相关创建/启用,读取、非准入属性修改和停用仍允许,已有流保持运行。
T-010 冻结 `bell.area_policy_v1` 的列顺序为 `tenant_id/site_id/area_id/capture_policy/source_version/source_updated_at`,策略仅允许 `video_allowed | non_imaging_only`。Sense 对所有 PostgreSQL 新建设备验证 Area 归属;具有 `video_capture` 能力的设备在创建(包括 disabled 创建)和启用时检查策略。缺失、非法、版本回退或不可读映射为 `area_policy_unavailable`,`non_imaging_only` 拒绝成像变更并映射为 `area_policy_denied`。同库视图实时读取,不把长期未修改记录的 `source_updated_at` 年龄误判为过期。
## 本地设备操作审计
`sense-device-audit-v1.schema.json` 继续冻结创建与期望态两类事实且不原地扩展严格枚举。T-011 新增 v2 后继,兼容 v1 两类事件并增加 `device.configuration.accepted`;该 payload 只保存是否变化、字段名和 Area 逻辑 ID,不保存字段值。主体类型为 `user | service | system`,投影版本与 generation 随事实保存;endpoint、credential、profile token、path、密码、完整流 URI 或 MediaMTX 配置始终禁止进入审计。
PostgreSQL repository 必须在设备创建/期望态事务内写 `sense.device_operation_outbox`;Outbox 失败回滚业务写入。相同期望态不增加 generation,但仍产生独立审计事实。Schema 不是 Bell relay 协议:传输端点、签名、批量确认、重放窗口和留存由后续任务冻结。
## 兼容与废弃
- v1 可增加不改变已有语义的可选响应字段和新错误细节;客户端必须忽略未知响应字段。
- 删除/重命名字段、收紧已接受输入、改变状态码/幂等作用域/配额计数或把只读视图改为远程调用,均属于破坏性变化,必须发布新版本。
- 废弃版本应先在 OpenAPI 标记并公告迁移窗口;服务端在所有已声明消费者完成迁移前继续提供旧版本。
- OpenAPI 中的稳定错误码用于程序判断,`message` 只用于人读,不得依赖其字面内容。
## 验证
从仓库根目录执行:
```powershell
python -m json.tool docs/contracts/sense-control-v1.openapi.json | Out-Null
python -m json.tool docs/contracts/sense-device-audit-v2.schema.json | Out-Null
python -m unittest discover -s tests -p "test_sense_control_contract.py"
python -m unittest discover -s tests -p "test_sense_control_implementation.py"
```
测试同时校验 OpenAPI 结构、生成 server glue、HTTP handler 与 PostgreSQL migration/事务;它不替代 Bell 消费方联合验收或客户现场容量验证。
+30
View File
@@ -0,0 +1,30 @@
-- Contract-only reference for the Bell -> Sense Area admission projection.
-- The executable migration is deploy/postgres/005_area_policy.sql.
CREATE OR REPLACE VIEW bell.area_policy_v1 (
tenant_id,
site_id,
area_id,
capture_policy,
source_version,
source_updated_at
) AS
SELECT
area.tenant_id,
area.site_id,
area.id,
area.capture_policy,
area.version,
area.updated_at
FROM bell.areas AS area
JOIN bell.sites AS site
ON site.tenant_id = area.tenant_id AND site.id = area.site_id
WHERE area.deleted_at IS NULL AND site.deleted_at IS NULL;
COMMENT ON VIEW bell.area_policy_v1 IS
'v1 read-only Area capture-policy projection owned by Bell and consumed by Sense';
ALTER VIEW bell.area_policy_v1 OWNER TO bell_app;
REVOKE ALL PRIVILEGES ON TABLE bell.area_policy_v1 FROM PUBLIC;
REVOKE ALL PRIVILEGES ON TABLE bell.area_policy_v1 FROM sense_app;
GRANT SELECT ON TABLE bell.area_policy_v1 TO sense_app;
@@ -0,0 +1,691 @@
{
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "YoVision Sense Control API",
"version": "1.0.0",
"description": "受控的设备期望态与收敛查询契约。Tenant 只从认证上下文确定;Site、Area、RBAC 和配额真相仍由 Bell 持有。"
},
"servers": [
{
"url": "/",
"description": "由部署入口提供 scheme、host 和认证终止"
}
],
"security": [
{
"bearerAuth": []
}
],
"tags": [
{
"name": "Devices",
"description": "设备台账、期望态和收敛状态"
},
{
"name": "Operations",
"description": "批量写入的逐项结果"
}
],
"paths": {
"/api/v1/sites/{site_id}/devices": {
"get": {
"tags": ["Devices"],
"operationId": "listDevices",
"summary": "分页查询当前认证租户下的站点设备",
"parameters": [
{"$ref": "#/components/parameters/SiteID"},
{"$ref": "#/components/parameters/Cursor"},
{"$ref": "#/components/parameters/Limit"},
{"$ref": "#/components/parameters/ModalityFilter"},
{"$ref": "#/components/parameters/CapabilityFilter"},
{"$ref": "#/components/parameters/DesiredStateFilter"},
{"$ref": "#/components/parameters/ActualStateFilter"}
],
"responses": {
"200": {
"description": "按 created_at、id 稳定升序返回的一页设备",
"headers": {"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}},
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/DevicePage"}}
}
},
"401": {"$ref": "#/components/responses/ErrorResponse"},
"404": {"$ref": "#/components/responses/ErrorResponse"},
"500": {"$ref": "#/components/responses/ErrorResponse"}
}
},
"post": {
"tags": ["Devices"],
"operationId": "createDevice",
"summary": "创建待探测设备",
"parameters": [
{"$ref": "#/components/parameters/SiteID"},
{"$ref": "#/components/parameters/IdempotencyKey"}
],
"requestBody": {
"required": true,
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/DeviceCreate"}}
}
},
"responses": {
"201": {"$ref": "#/components/responses/CreatedDevice"},
"400": {"$ref": "#/components/responses/ErrorResponse"},
"401": {"$ref": "#/components/responses/ErrorResponse"},
"404": {"$ref": "#/components/responses/ErrorResponse"},
"409": {"$ref": "#/components/responses/ErrorResponse"},
"422": {"$ref": "#/components/responses/ErrorResponse"},
"503": {"$ref": "#/components/responses/ErrorResponse"}
}
}
},
"/api/v1/sites/{site_id}/devices/{device_id}": {
"get": {
"tags": ["Devices"],
"operationId": "getDevice",
"summary": "读取设备期望态、实际态和收敛进度",
"parameters": [
{"$ref": "#/components/parameters/SiteID"},
{"$ref": "#/components/parameters/DeviceID"}
],
"responses": {
"200": {"$ref": "#/components/responses/CurrentDevice"},
"401": {"$ref": "#/components/responses/ErrorResponse"},
"404": {"$ref": "#/components/responses/ErrorResponse"},
"500": {"$ref": "#/components/responses/ErrorResponse"}
}
},
"patch": {
"tags": ["Devices"],
"operationId": "updateDevice",
"summary": "按 ETag 修改设备可编辑配置",
"parameters": [
{"$ref": "#/components/parameters/SiteID"},
{"$ref": "#/components/parameters/DeviceID"},
{"$ref": "#/components/parameters/IfMatch"}
],
"requestBody": {
"required": true,
"content": {
"application/merge-patch+json": {"schema": {"$ref": "#/components/schemas/DevicePatch"}}
}
},
"responses": {
"202": {"$ref": "#/components/responses/AcceptedMutation"},
"400": {"$ref": "#/components/responses/ErrorResponse"},
"401": {"$ref": "#/components/responses/ErrorResponse"},
"404": {"$ref": "#/components/responses/ErrorResponse"},
"409": {"$ref": "#/components/responses/ErrorResponse"},
"412": {"$ref": "#/components/responses/ErrorResponse"},
"422": {"$ref": "#/components/responses/ErrorResponse"},
"428": {"$ref": "#/components/responses/ErrorResponse"},
"503": {"$ref": "#/components/responses/ErrorResponse"}
}
}
},
"/api/v1/sites/{site_id}/devices/{device_id}/desired-state": {
"put": {
"tags": ["Devices"],
"operationId": "setDeviceDesiredState",
"summary": "启用或停用设备接入期望态",
"description": "成功只表示期望态已持久化并进入对账,不表示实际态已经收敛。重复提交不会重复改变 generation,但每次请求均可产生审计事实。",
"parameters": [
{"$ref": "#/components/parameters/SiteID"},
{"$ref": "#/components/parameters/DeviceID"},
{"$ref": "#/components/parameters/IfMatch"}
],
"requestBody": {
"required": true,
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/DesiredStateChange"}}
}
},
"responses": {
"202": {"$ref": "#/components/responses/AcceptedMutation"},
"400": {"$ref": "#/components/responses/ErrorResponse"},
"401": {"$ref": "#/components/responses/ErrorResponse"},
"404": {"$ref": "#/components/responses/ErrorResponse"},
"412": {"$ref": "#/components/responses/ErrorResponse"},
"422": {"$ref": "#/components/responses/ErrorResponse"},
"428": {"$ref": "#/components/responses/ErrorResponse"},
"503": {"$ref": "#/components/responses/ErrorResponse"}
}
}
},
"/api/v1/sites/{site_id}/devices:batchDesiredState": {
"post": {
"tags": ["Devices"],
"operationId": "batchSetDeviceDesiredState",
"summary": "批量提交最多 128 个设备期望态变更",
"parameters": [
{"$ref": "#/components/parameters/SiteID"},
{"$ref": "#/components/parameters/IdempotencyKey"}
],
"requestBody": {
"required": true,
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/BatchDesiredStateRequest"}}
}
},
"responses": {
"202": {
"description": "请求已接收;响应包含已知的逐项接受/拒绝结果,后续状态从 operation 读取",
"headers": {
"Location": {"$ref": "#/components/headers/OperationLocation"},
"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}
},
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/BatchOperation"}}
}
},
"400": {"$ref": "#/components/responses/ErrorResponse"},
"401": {"$ref": "#/components/responses/ErrorResponse"},
"404": {"$ref": "#/components/responses/ErrorResponse"},
"409": {"$ref": "#/components/responses/ErrorResponse"},
"422": {"$ref": "#/components/responses/ErrorResponse"},
"503": {"$ref": "#/components/responses/ErrorResponse"}
}
}
},
"/api/v1/operations/{operation_id}": {
"get": {
"tags": ["Operations"],
"operationId": "getOperation",
"summary": "查询批量写入和逐项结果",
"parameters": [
{"$ref": "#/components/parameters/OperationID"}
],
"responses": {
"200": {
"description": "批量操作状态",
"headers": {"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}},
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/BatchOperation"}}
}
},
"401": {"$ref": "#/components/responses/ErrorResponse"},
"404": {"$ref": "#/components/responses/ErrorResponse"},
"500": {"$ref": "#/components/responses/ErrorResponse"}
}
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "opaque",
"description": "认证实现与 claim 格式不属于 v1 契约;服务端必须从已验证主体确定 tenant 与权限。"
}
},
"parameters": {
"SiteID": {
"name": "site_id",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/LogicalID"},
"description": "当前认证租户中的稳定站点 ID"
},
"DeviceID": {
"name": "device_id",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/LogicalID"}
},
"OperationID": {
"name": "operation_id",
"in": "path",
"required": true,
"schema": {"type": "string", "pattern": "^op_[0-9A-HJKMNP-TV-Z]{26}$"}
},
"Cursor": {
"name": "cursor",
"in": "query",
"required": false,
"schema": {"type": "string", "minLength": 16, "maxLength": 512},
"description": "不透明游标;客户端不得解析或构造"
},
"Limit": {
"name": "limit",
"in": "query",
"required": false,
"schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}
},
"ModalityFilter": {
"name": "modality",
"in": "query",
"required": false,
"schema": {"$ref": "#/components/schemas/Modality"}
},
"CapabilityFilter": {
"name": "capability",
"in": "query",
"required": false,
"schema": {"$ref": "#/components/schemas/Capability"}
},
"DesiredStateFilter": {
"name": "desired_state",
"in": "query",
"required": false,
"schema": {"$ref": "#/components/schemas/DesiredState"}
},
"ActualStateFilter": {
"name": "actual_state",
"in": "query",
"required": false,
"schema": {"$ref": "#/components/schemas/ActualState"}
},
"IdempotencyKey": {
"name": "Idempotency-Key",
"in": "header",
"required": true,
"schema": {
"type": "string",
"minLength": 16,
"maxLength": 128,
"pattern": "^[A-Za-z0-9._:-]+$"
},
"description": "同一认证主体、站点、operationId、key 和规范化 body 在 24 小时内返回同一结果;key 相同而 body 不同返回 idempotency_conflict。"
},
"IfMatch": {
"name": "If-Match",
"in": "header",
"required": true,
"schema": {"type": "string", "minLength": 3, "maxLength": 128},
"description": "使用最近一次 GET/写响应的 opaque ETag;不匹配返回 412 etag_mismatch。"
}
},
"headers": {
"ETag": {
"description": "设备资源的不透明并发版本",
"schema": {"type": "string"}
},
"TraceID": {
"description": "不含租户、主机或凭据的排查 ID",
"schema": {"type": "string", "minLength": 8, "maxLength": 128}
},
"DeviceLocation": {
"description": "新建设备资源的相对路径",
"schema": {"type": "string", "pattern": "^/api/v1/sites/[^/]+/devices/[^/]+$"}
},
"OperationLocation": {
"description": "批量操作资源的相对路径",
"schema": {"type": "string", "pattern": "^/api/v1/operations/op_[0-9A-HJKMNP-TV-Z]{26}$"}
}
},
"responses": {
"CurrentDevice": {
"description": "设备当前表示;endpoint 与 credential ref 永不回显",
"headers": {
"ETag": {"$ref": "#/components/headers/ETag"},
"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}
},
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/Device"}}
}
},
"CreatedDevice": {
"description": "设备已创建;启用设备仍需等待对账收敛",
"headers": {
"ETag": {"$ref": "#/components/headers/ETag"},
"Location": {"$ref": "#/components/headers/DeviceLocation"},
"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}
},
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/Device"}}
}
},
"AcceptedMutation": {
"description": "期望态/配置写入已接受,实际态异步收敛",
"headers": {
"ETag": {"$ref": "#/components/headers/ETag"},
"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}
},
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/MutationReceipt"}}
}
},
"ErrorResponse": {
"description": "稳定错误;不返回内部堆栈、资源存在性或连接秘密",
"headers": {"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}},
"content": {
"application/problem+json": {"schema": {"$ref": "#/components/schemas/Problem"}}
}
}
},
"schemas": {
"LogicalID": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$"
},
"Modality": {
"type": "string",
"enum": ["video", "radar", "contact", "button", "wearable", "other"]
},
"Capability": {
"type": "string",
"enum": ["video_capture", "audio_capture", "spatial_rule", "telemetry"]
},
"DesiredState": {
"type": "string",
"enum": ["disabled", "enabled"]
},
"ActualState": {
"type": "string",
"enum": ["pending", "online", "offline", "failed"]
},
"AdapterStatus": {
"type": "string",
"enum": ["pending", "ready", "adapter_not_ready", "authentication_failed", "unavailable"]
},
"ErrorCode": {
"type": "string",
"enum": [
"invalid_request",
"unauthenticated",
"forbidden",
"not_found",
"conflict",
"precondition_required",
"etag_mismatch",
"idempotency_conflict",
"duplicate_serial_number",
"quota_exceeded",
"quota_projection_unavailable",
"quota_projection_invalid",
"area_policy_denied",
"area_policy_unavailable",
"adapter_not_ready",
"authentication_failed",
"endpoint_credentials_forbidden",
"batch_too_large",
"service_unavailable",
"internal_error"
]
},
"DeviceCreate": {
"type": "object",
"additionalProperties": false,
"required": ["serial_number", "name", "modality", "capabilities", "area_id"],
"properties": {
"serial_number": {"type": "string", "minLength": 1, "maxLength": 128},
"name": {"type": "string", "minLength": 1, "maxLength": 200},
"modality": {"$ref": "#/components/schemas/Modality"},
"capabilities": {
"type": "array",
"minItems": 1,
"maxItems": 16,
"uniqueItems": true,
"items": {"$ref": "#/components/schemas/Capability"},
"description": "请求接入的能力集合;适配器探测不支持时以稳定错误拒绝或标记 adapter_not_ready。"
},
"area_id": {"$ref": "#/components/schemas/LogicalID"},
"endpoint_ref": {
"type": "string",
"format": "uri",
"minLength": 1,
"maxLength": 2048,
"writeOnly": true,
"description": "绝对 URI,禁止 userinfo;只在受控写路径使用。"
},
"credential_ref": {
"type": "string",
"minLength": 1,
"maxLength": 512,
"writeOnly": true,
"description": "不透明密钥引用,不是用户名、密码或 token。"
},
"profile_token": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"writeOnly": true
},
"desired_state": {
"allOf": [{"$ref": "#/components/schemas/DesiredState"}],
"default": "disabled"
}
},
"allOf": [
{
"if": {
"required": ["modality"],
"properties": {"modality": {"const": "video"}}
},
"then": {
"properties": {
"capabilities": {"contains": {"const": "video_capture"}, "minContains": 1}
}
}
},
{
"if": {
"required": ["capabilities"],
"properties": {
"capabilities": {"contains": {"const": "video_capture"}, "minContains": 1}
}
},
"then": {"required": ["endpoint_ref", "credential_ref"]}
}
]
},
"DevicePatch": {
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 200},
"area_id": {"$ref": "#/components/schemas/LogicalID"},
"endpoint_ref": {
"type": "string",
"format": "uri",
"minLength": 1,
"maxLength": 2048,
"writeOnly": true,
"description": "绝对 URI,禁止 userinfo。"
},
"credential_ref": {
"type": "string",
"minLength": 1,
"maxLength": 512,
"writeOnly": true
},
"profile_token": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"writeOnly": true
}
}
},
"DesiredStateChange": {
"type": "object",
"additionalProperties": false,
"required": ["desired_state", "reason"],
"properties": {
"desired_state": {"$ref": "#/components/schemas/DesiredState"},
"reason": {"type": "string", "minLength": 1, "maxLength": 500}
}
},
"Device": {
"type": "object",
"additionalProperties": false,
"required": [
"id", "tenant_id", "site_id", "serial_number", "name", "modality",
"capabilities", "area_id", "desired_state", "actual_state", "adapter_status",
"endpoint_configured", "credential_configured", "generation", "observed_generation",
"converged", "failure_count", "projection_versions", "created_at", "updated_at"
],
"properties": {
"id": {"$ref": "#/components/schemas/LogicalID"},
"tenant_id": {
"allOf": [{"$ref": "#/components/schemas/LogicalID"}],
"readOnly": true,
"description": "只用于回显已授权上下文,不得由客户端写入。"
},
"site_id": {"$ref": "#/components/schemas/LogicalID"},
"serial_number": {"type": "string", "minLength": 1, "maxLength": 128},
"name": {"type": "string", "minLength": 1, "maxLength": 200},
"modality": {"$ref": "#/components/schemas/Modality"},
"capabilities": {
"type": "array",
"items": {"$ref": "#/components/schemas/Capability"},
"uniqueItems": true,
"maxItems": 16,
"readOnly": true
},
"area_id": {"$ref": "#/components/schemas/LogicalID"},
"desired_state": {"$ref": "#/components/schemas/DesiredState"},
"actual_state": {"$ref": "#/components/schemas/ActualState"},
"adapter_status": {"$ref": "#/components/schemas/AdapterStatus"},
"endpoint_configured": {"type": "boolean", "readOnly": true},
"credential_configured": {"type": "boolean", "readOnly": true},
"generation": {"type": "integer", "format": "int64", "minimum": 1, "readOnly": true},
"observed_generation": {"type": "integer", "format": "int64", "minimum": 0, "readOnly": true},
"converged": {
"type": "boolean",
"readOnly": true,
"description": "期望代已被观察且当前无待重试;不等价于所有外部系统健康。"
},
"failure_count": {"type": "integer", "minimum": 0, "readOnly": true},
"next_attempt_at": {"type": ["string", "null"], "format": "date-time", "readOnly": true},
"last_error_code": {"type": ["string", "null"], "maxLength": 128, "readOnly": true},
"projection_versions": {"$ref": "#/components/schemas/ProjectionVersions"},
"created_at": {"type": "string", "format": "date-time", "readOnly": true},
"updated_at": {"type": "string", "format": "date-time", "readOnly": true}
}
},
"ProjectionVersions": {
"type": "object",
"additionalProperties": false,
"required": ["quota_source_version", "area_policy_source_version", "synced_at"],
"properties": {
"quota_source_version": {"type": ["integer", "null"], "format": "int64", "minimum": 1, "readOnly": true},
"area_policy_source_version": {"type": ["integer", "null"], "format": "int64", "minimum": 1, "readOnly": true},
"synced_at": {"type": ["string", "null"], "format": "date-time", "readOnly": true}
}
},
"SiteQuotaStatus": {
"type": "object",
"additionalProperties": false,
"required": ["status", "used_video_channels", "max_video_channels", "available_video_channels", "over_limit", "source_version", "synced_at"],
"properties": {
"status": {"type": "string", "enum": ["current", "unavailable", "invalid"]},
"used_video_channels": {"type": "integer", "minimum": 0, "maximum": 128},
"max_video_channels": {"type": ["integer", "null"], "minimum": 1, "maximum": 128},
"available_video_channels": {"type": ["integer", "null"], "minimum": 0, "maximum": 128},
"over_limit": {"type": "boolean"},
"source_version": {"type": ["integer", "null"], "format": "int64", "minimum": 1},
"synced_at": {"type": ["string", "null"], "format": "date-time"}
}
},
"PageInfo": {
"type": "object",
"additionalProperties": false,
"required": ["limit", "has_more", "next_cursor"],
"properties": {
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
"has_more": {"type": "boolean"},
"next_cursor": {"type": ["string", "null"], "maxLength": 512}
}
},
"DevicePage": {
"type": "object",
"additionalProperties": false,
"required": ["items", "page", "quota"],
"properties": {
"items": {"type": "array", "maxItems": 100, "items": {"$ref": "#/components/schemas/Device"}},
"page": {"$ref": "#/components/schemas/PageInfo"},
"quota": {"$ref": "#/components/schemas/SiteQuotaStatus"}
}
},
"MutationReceipt": {
"type": "object",
"additionalProperties": false,
"required": ["device", "accepted_at", "trace_id"],
"properties": {
"device": {"$ref": "#/components/schemas/Device"},
"accepted_at": {"type": "string", "format": "date-time"},
"trace_id": {"type": "string", "minLength": 8, "maxLength": 128}
}
},
"BatchDesiredStateItem": {
"type": "object",
"additionalProperties": false,
"required": ["device_id", "etag", "desired_state"],
"properties": {
"device_id": {"$ref": "#/components/schemas/LogicalID"},
"etag": {"type": "string", "minLength": 3, "maxLength": 128},
"desired_state": {"$ref": "#/components/schemas/DesiredState"}
}
},
"BatchDesiredStateRequest": {
"type": "object",
"additionalProperties": false,
"required": ["items", "reason"],
"properties": {
"items": {
"type": "array",
"minItems": 1,
"maxItems": 128,
"items": {"$ref": "#/components/schemas/BatchDesiredStateItem"}
},
"reason": {"type": "string", "minLength": 1, "maxLength": 500}
}
},
"BatchItemResult": {
"type": "object",
"additionalProperties": false,
"required": ["device_id", "status", "error_code", "message", "generation"],
"properties": {
"device_id": {"$ref": "#/components/schemas/LogicalID"},
"status": {"type": "string", "enum": ["accepted", "rejected", "succeeded", "failed"]},
"error_code": {"oneOf": [{"$ref": "#/components/schemas/ErrorCode"}, {"type": "null"}]},
"message": {"type": ["string", "null"], "maxLength": 500},
"generation": {"type": ["integer", "null"], "format": "int64", "minimum": 1}
}
},
"BatchOperation": {
"type": "object",
"additionalProperties": false,
"required": ["id", "status", "submitted_at", "completed_at", "results", "trace_id"],
"properties": {
"id": {"type": "string", "pattern": "^op_[0-9A-HJKMNP-TV-Z]{26}$"},
"status": {"type": "string", "enum": ["queued", "running", "succeeded", "partially_succeeded", "failed"]},
"submitted_at": {"type": "string", "format": "date-time"},
"completed_at": {"type": ["string", "null"], "format": "date-time"},
"results": {"type": "array", "maxItems": 128, "items": {"$ref": "#/components/schemas/BatchItemResult"}},
"trace_id": {"type": "string", "minLength": 8, "maxLength": 128}
}
},
"FieldError": {
"type": "object",
"additionalProperties": false,
"required": ["field", "code", "message"],
"properties": {
"field": {"type": "string", "maxLength": 256},
"code": {"type": "string", "maxLength": 128},
"message": {"type": "string", "maxLength": 500}
}
},
"Problem": {
"type": "object",
"additionalProperties": false,
"required": ["type", "title", "status", "code", "message", "trace_id", "field_errors"],
"properties": {
"type": {"type": "string", "format": "uri-reference"},
"title": {"type": "string", "maxLength": 200},
"status": {"type": "integer", "minimum": 400, "maximum": 599},
"code": {"$ref": "#/components/schemas/ErrorCode"},
"message": {"type": "string", "maxLength": 1000},
"trace_id": {"type": "string", "minLength": 8, "maxLength": 128},
"field_errors": {"type": "array", "maxItems": 100, "items": {"$ref": "#/components/schemas/FieldError"}}
}
}
}
}
}
@@ -0,0 +1,109 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/sense-device-audit-v1.schema.json",
"title": "Sense Device Audit Event v1",
"description": "Sense 本地持久化的设备操作审计事实;不定义 Bell relay 的传输、签名、确认或留存。",
"type": "object",
"additionalProperties": false,
"required": [
"event_id",
"event_type",
"tenant_id",
"site_id",
"device_id",
"actor",
"reason",
"trace_id",
"aggregate_generation",
"projection_versions",
"data",
"occurred_at"
],
"properties": {
"event_id": {
"type": "string",
"pattern": "^audit_[0-9a-f]{32}$"
},
"event_type": {
"enum": ["device.created", "device.desired_state.accepted"]
},
"tenant_id": {"$ref": "#/$defs/logicalId"},
"site_id": {"$ref": "#/$defs/logicalId"},
"device_id": {"$ref": "#/$defs/logicalId"},
"actor": {
"type": "object",
"additionalProperties": false,
"required": ["type", "id"],
"properties": {
"type": {"enum": ["user", "service", "system"]},
"id": {"type": "string", "minLength": 1, "maxLength": 200}
}
},
"reason": {"type": ["string", "null"], "maxLength": 500},
"trace_id": {"type": ["string", "null"], "maxLength": 128},
"aggregate_generation": {"type": "integer", "minimum": 1},
"projection_versions": {
"type": "object",
"additionalProperties": false,
"required": ["quota_source_version", "area_policy_source_version"],
"properties": {
"quota_source_version": {"type": ["integer", "null"], "minimum": 1},
"area_policy_source_version": {"type": ["integer", "null"], "minimum": 1}
}
},
"data": {
"oneOf": [
{"$ref": "#/$defs/deviceCreatedData"},
{"$ref": "#/$defs/desiredStateData"}
]
},
"occurred_at": {"type": "string", "format": "date-time"}
},
"allOf": [
{
"if": {"properties": {"event_type": {"const": "device.created"}}},
"then": {"properties": {"data": {"$ref": "#/$defs/deviceCreatedData"}}}
},
{
"if": {"properties": {"event_type": {"const": "device.desired_state.accepted"}}},
"then": {"properties": {"data": {"$ref": "#/$defs/desiredStateData"}}}
}
],
"$defs": {
"logicalId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
},
"desiredState": {"enum": ["disabled", "enabled"]},
"deviceCreatedData": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "area_id", "modality", "capabilities", "desired_state"],
"properties": {
"kind": {"const": "device_created"},
"area_id": {"$ref": "#/$defs/logicalId"},
"modality": {"enum": ["video", "radar", "contact", "button", "wearable", "other"]},
"capabilities": {
"type": "array",
"maxItems": 16,
"uniqueItems": true,
"items": {"enum": ["video_capture", "audio_capture", "spatial_rule", "telemetry"]}
},
"desired_state": {"$ref": "#/$defs/desiredState"}
}
},
"desiredStateData": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "previous_desired_state", "desired_state", "changed"],
"properties": {
"kind": {"const": "desired_state_accepted"},
"previous_desired_state": {"$ref": "#/$defs/desiredState"},
"desired_state": {"$ref": "#/$defs/desiredState"},
"changed": {"type": "boolean"}
}
}
}
}
@@ -0,0 +1,124 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/sense-device-audit-v2.schema.json",
"title": "Sense Device Audit Event v2",
"description": "v1 的向后兼容后继:增加脱敏设备配置受理事实,仍不定义 Bell relay。",
"type": "object",
"additionalProperties": false,
"required": [
"event_id", "event_type", "tenant_id", "site_id", "device_id",
"actor", "reason", "trace_id", "aggregate_generation",
"projection_versions", "data", "occurred_at"
],
"properties": {
"event_id": {"type": "string", "pattern": "^audit_[0-9a-f]{32}$"},
"event_type": {
"enum": [
"device.created",
"device.desired_state.accepted",
"device.configuration.accepted"
]
},
"tenant_id": {"$ref": "#/$defs/logicalId"},
"site_id": {"$ref": "#/$defs/logicalId"},
"device_id": {"$ref": "#/$defs/logicalId"},
"actor": {
"type": "object",
"additionalProperties": false,
"required": ["type", "id"],
"properties": {
"type": {"enum": ["user", "service", "system"]},
"id": {"type": "string", "minLength": 1, "maxLength": 200}
}
},
"reason": {"type": ["string", "null"], "maxLength": 500},
"trace_id": {"type": ["string", "null"], "maxLength": 128},
"aggregate_generation": {"type": "integer", "minimum": 1},
"projection_versions": {
"type": "object",
"additionalProperties": false,
"required": ["quota_source_version", "area_policy_source_version"],
"properties": {
"quota_source_version": {"type": ["integer", "null"], "minimum": 1},
"area_policy_source_version": {"type": ["integer", "null"], "minimum": 1}
}
},
"data": {
"oneOf": [
{"$ref": "#/$defs/deviceCreatedData"},
{"$ref": "#/$defs/desiredStateData"},
{"$ref": "#/$defs/configurationData"}
]
},
"occurred_at": {"type": "string", "format": "date-time"}
},
"allOf": [
{
"if": {"properties": {"event_type": {"const": "device.created"}}},
"then": {"properties": {"data": {"$ref": "#/$defs/deviceCreatedData"}}}
},
{
"if": {"properties": {"event_type": {"const": "device.desired_state.accepted"}}},
"then": {"properties": {"data": {"$ref": "#/$defs/desiredStateData"}}}
},
{
"if": {"properties": {"event_type": {"const": "device.configuration.accepted"}}},
"then": {"properties": {"data": {"$ref": "#/$defs/configurationData"}}}
}
],
"$defs": {
"logicalId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
},
"desiredState": {"enum": ["disabled", "enabled"]},
"deviceCreatedData": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "area_id", "modality", "capabilities", "desired_state"],
"properties": {
"kind": {"const": "device_created"},
"area_id": {"$ref": "#/$defs/logicalId"},
"modality": {"enum": ["video", "radar", "contact", "button", "wearable", "other"]},
"capabilities": {
"type": "array",
"maxItems": 16,
"uniqueItems": true,
"items": {"enum": ["video_capture", "audio_capture", "spatial_rule", "telemetry"]}
},
"desired_state": {"$ref": "#/$defs/desiredState"}
}
},
"desiredStateData": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "previous_desired_state", "desired_state", "changed"],
"properties": {
"kind": {"const": "desired_state_accepted"},
"previous_desired_state": {"$ref": "#/$defs/desiredState"},
"desired_state": {"$ref": "#/$defs/desiredState"},
"changed": {"type": "boolean"}
}
},
"configurationData": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "changed", "changed_fields", "area_id"],
"properties": {
"kind": {"const": "configuration_accepted"},
"changed": {"type": "boolean"},
"changed_fields": {
"type": "array",
"maxItems": 5,
"uniqueItems": true,
"items": {
"enum": ["name", "area_id", "endpoint_ref", "credential_ref", "profile_token"]
}
},
"area_id": {"$ref": "#/$defs/logicalId"}
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
-- YoVision Bell -> Sense site quota projection contract v1.
--
-- This file freezes the PostgreSQL object signature and privileges. It is not a
-- migration: Bell must create the source table and install this view in a later
-- implementation task. The source table must enforce a default of 16 and a
-- CHECK constraint that keeps max_video_channels between 1 and 128 inclusive.
-- source_version must increase monotonically for each logical site whenever a
-- projected value changes.
CREATE VIEW bell.site_quota_v1 (
tenant_id,
site_id,
max_video_channels,
source_version,
source_updated_at
) AS
SELECT
site.tenant_id,
site.id,
site.max_video_channels,
site.version,
site.updated_at
FROM bell.sites AS site
WHERE site.deleted_at IS NULL;
COMMENT ON VIEW bell.site_quota_v1 IS
'v1 read-only site video quota projection owned by Bell and consumed by Sense';
ALTER VIEW bell.site_quota_v1 OWNER TO bell_app;
REVOKE ALL PRIVILEGES ON TABLE bell.site_quota_v1 FROM PUBLIC;
GRANT USAGE ON SCHEMA bell TO sense_app;
GRANT SELECT ON TABLE bell.site_quota_v1 TO sense_app;
+44 -9
View File
@@ -1,21 +1,32 @@
# 当前实现状态
> 快照日期:2026-08-03。只记录仓库现实与 blocker;任务实时状态到 Gitea Issue 查看。
> 快照日期:2026-08-10。只记录仓库现实与 blocker;任务实时状态到 Gitea Issue 查看。
## 当前阶段
- 阶段:M0 摄像头兼容性验证与需求定稿。
- 生产代码:尚未开始。
- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;客户网络尚未提供,WireGuard T-013 后置,五条独立真实上游和生产 SLA 仍未验收。下一开发重点转向 M3 的 Bell 事件存储与审计 relay 边界。
- 生产代码:Sense 已包含可构建进程、SQLite/PostgreSQL repository、Site/Area 准入、设备操作 Outbox、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、Control API v1、对账/探活、数据库租约、孤儿只读扫描/受控命令、低基数指标和可重复 16 路容量脚本;Bell 管理服务/JWT、Outbox relay、WireGuard 和完整生产部署仍未实现。
- 默认容量:16 路;单站点本阶段上限 128 路,必须横向分片。
## 仓库现实
- `Sense/`、`Brain/`、`Bell/` 只有目录占位。
- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Brain/`、`Bell/` 仍只有目录占位,Bell 的首个数据库源表/视图 migration 位于根目录 `deploy/postgres/`。
- Sense 设备模型使用 `modality + capabilities`,SQLite 执行 v1 migration;视频配额默认 16、允许 1~128,17/128/129、新增/启用和“降低配额不关闭已有流”均有测试。
- T-009 冻结 PostgreSQL `17.10` 和 `pgx/v5 v5.10.0`,实现 `bell`/`sense` schema、NOLOGIN 权限角色、Bell Site 版本 trigger、`bell.site_quota_v1` 和 Sense PostgreSQL repository;同站点并发准入用事务级 advisory lock,配额缺失/越界/版本回退时失败关闭且不改变已有流。
- T-010 增量实现 `bell.areas`、`bell.area_policy_v1`、Area 版本观察和 `sense.device_operation_outbox`;`non_imaging_only` 拒绝成像设备创建/启用,失败不改变已有设备。设备创建/期望态受理与脱敏 Outbox 同事务,相同期望态不增加 generation 但仍审计。
- Windows 隔离测试使用 `D:\pgsql17\bin` 启动随机回环端口临时集群,`001`~`011` migration 可重放、权限断言和 29 个真实 PostgreSQL 测试通过后自动清理;现有 `D:\pgsql17\data` 和 5432 服务未被读取、停止或修改。
- MediaMTX 固定为独立二进制 `v1.19.3`,官方 OpenAPI 已按 SHA-256 vendoring,并由固定 `oapi-codegen v2.8.0` 生成客户端;手写薄封装有 create/read/delete、幂等 ensure、探活和只返回名称的受限分页枚举测试。
- T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;T-006 增加真实 ONVIF adapter、RTSP router、实验室播种/状态工具、故障代理和五路自动验收。T-012 的普通调和不枚举孤儿;独立 PostgreSQL 扫描默认只报告,未知归属永不删除。
- T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。
- T-014 正式使用隔离 PostgreSQL、真实 Control API、两套 MediaMTX 和 16 个独立低码率合成 publisher,完成 17 路配额拒绝、三轮 `16 → 0 → 16` 批量收敛和固定四路故障恢复;稳定观察 `1800.1 s` / 180 次采样,最大与最终 `unconverged` 均为 0、最终在线 Path 16、帧错误 0。证据见 `docs/research/sense-16-stream-capacity.md`;不外推到真实 16 机、网络、录像、AI/GPU、64/128 路或生产 SLA。
- `docs/raw/01`~`08` 已记录需求、分析、方案、客户场景、事件比对和三系统职责。
- `docs/raw/contracts/event-v0.1.schema.json` 已冻结,并有多份示例与语义说明。
- `docs/contracts/sense-control-v1.openapi.json` 的 7 个站点作用域/operation endpoint 已由 T-011 实现:外部静态 SHA-256 主体注册表、tenant/Site scope、HMAC cursor、ETag、PostgreSQL 24 小时幂等收据和最多 128 项 batch operation 均有代码与隔离集成测试。默认 SQLite 只暴露运维探针与低基数 `/metrics`,不注册业务路由;Bell 管理服务、JWT/OIDC 和 Outbox relay 尚未实现。
- T-012 把 PostgreSQL schema 提升到 v5:due row 用数据库时钟、`FOR UPDATE SKIP LOCKED`、逐项续租和 fencing token 协调;MediaMTX Path 历史归属、15 分钟孤儿快照、最多 10%/128 项安全闸、无 bypass 的本地处置命令及 `/metrics` 已实现。SQLite 明确保留单实例开发语义。
- harness coding 文档、上下文清单、Gitea Issue/PR 模板和治理脚本已接入。
- Gitea 已初始化 11 个协作标签;首批 Issue 已建立,实时可领取状态必须从 Gitea 查询,不在本文复制。
- Gitea 已初始化 12 个协作标签;`status/waiting` 用于依赖或外部条件未满足的未领取任务,实时可领取状态必须从 Gitea 查询,不在本文复制。
- T-002 已关闭架构影响型问题:首期 S2 民办寄宿学校、客户侧私有部署、ONVIF/RTSP、NVIDIA 主路径、自研 Bell、Web/H5 与客户侧证据存储等边界已批准。
- T-004 的 Sense/Bell 单文件交互原型已获产品确认;T-005 已归档 Claude 原稿并补录缺失评审文档,当前定稿与原始依据分开保存。
- `_reference/mibeenvr` 为本地只读参考仓库,已被忽略;只允许 M0 实验与白名单借鉴。
## 当前可运行命令
@@ -26,6 +37,12 @@ Windows:
./init.ps1
```
该入口会下载锁定 Go module,运行三条治理验证、MediaMTX 客户端生成漂移检查、`go test`、`go vet` 和 `go build`。只启动 Sense:
```powershell
go -C Sense run ./cmd/sense-api
```
跨平台直接验证:
```powershell
@@ -34,20 +51,38 @@ python -m unittest discover -s tests -p "test_*.py"
python scripts/validate_harness_governance.py
```
当前没有 Go/Python 业务依赖安装、生产服务启动或端到端命令。M1 脚手架创建时必须同步更新标准入口。
本机 PostgreSQL 17.10 隔离验证:
```powershell
./scripts/test_postgres.ps1 -PgRoot D:\pgsql17
```
本机 16 路软件容量预检与正式验证:
```powershell
./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -PreflightOnly
./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -OutputPath (Join-Path $env:TEMP 'yovision-t014-formal.json')
```
只有默认 30 分钟且输出 `formal_eligible=true` 的单次完整运行可作正式证据;`-ObservationMinutes 1` 只用于 smoke。
Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz`、`/readyz` 运维探针和低基数 `/metrics`;探针不代表摄像头或 M1 里程碑健康。MediaMTX 获取、校验和独立启动方法见 `Sense/README.md`。
## 当前 blocker / 待确认
- 尚未完成 3–5 款候选摄像头的 ONVIF/RTSP 实机矩阵和采购白名单。
- T-001 已完成一台 HIKVISION `DS-2CD3321FD-IW1-T`、硬件 `0x0`、固件 `V5.5.61 build 180929` 的 ONVIF/RTSP 指定基线准入;主/子码流、错误鉴权、三个 ONVIF 核心操作和时间漂移均有脱敏证据。项目负责人豁免了无法现场执行的三次真实断网恢复,该项没有原始时间线,单样机结论也不代表批次或多品牌兼容。
- 后续本地开发统一使用现有一台 Hikvision IP Camera;T-006 已用该实机 + 4 条独立 FFmpeg 8.1.2 合成 RTSP 源完成五路软件闭环,不要求当前购买更多摄像头。
- T-007 保留至少 5 条独立真实上游的客户现场门禁,可使用客户授权、借用或租赁设备;它不阻塞本地开发/M1 实验室出口,但继续阻塞生产试点和真实多路 SLA。
- S2 真实生产试点的未成年人影像、公共安全视频法规适用性和最终留存政策仍需客户/法务确认,阻塞 M3 上线但不阻塞 M1 实验室骨架。
- 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。
- 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。
- Go/Python/PostgreSQL/MediaMTX/Savant 的精确版本、目标硬件和 Bell 前端栈尚未冻结。
- Python/Savant 的精确版本、目标硬件和 Bell 前端栈尚未冻结;Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。
- 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009~T-014 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、登录角色、外部 Control API 安全文件与备份方案。
- 代码知识图谱在无业务代码阶段可能为空;工具不可用时使用 `rg` 处理文档与配置。
## 下一步
从 Gitea 的 `status/todo` 工单中由 dispatcher 分配依赖已满足、编号最靠前且写路径不冲突的任务。下一项优先完成 T-001(摄像头兼容性矩阵);T-003 仍需等待 T-001,T-002 已满足。不要仅凭本文宣称领取成功。
客户网络仍未提供,T-013 WireGuard 继续后置。下一项建议拆分并冻结 M3 的 Bell 不可变事件接收/存储,以及 Sense 设备操作 Outbox 到 Bell 全局审计的幂等 relay 协议与实现;两者不要在一个任务中混写。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-014 只解除本地默认 16 路软件基线缺口,不解除 T-007/T-013,也不形成真实多路或生产 SLA 承诺。
## 已知风险
+999
View File
@@ -0,0 +1,999 @@
<!doctype html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<link rel="icon" href="data:,">
<title>YoVision Bell · 预警处置原型</title>
<style>
:root {
--bg: #090d18;
--surface: #101725;
--surface-2: #151e2e;
--surface-3: #1b2638;
--text: #f5f7fb;
--muted: #a3b0c2;
--subtle: #73839a;
--line: #29364a;
--primary: #8b7cf6;
--primary-strong: #7161e8;
--primary-soft: rgba(139, 124, 246, .14);
--cyan: #38bdf8;
--success: #34d399;
--warning: #fbbf24;
--danger: #fb7185;
--shadow: 0 18px 46px rgba(0, 0, 0, .25);
--radius: 14px;
--sidebar: 216px;
--banner: 28px;
}
html[data-theme="light"] {
--bg: #f1f4f8;
--surface: #ffffff;
--surface-2: #f8fafc;
--surface-3: #eaf0f6;
--text: #131d2c;
--muted: #52647a;
--subtle: #738096;
--line: #d2dbe6;
--primary-soft: rgba(113, 97, 232, .1);
--shadow: 0 14px 34px rgba(32, 52, 78, .12);
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { min-width: 320px; background: var(--bg); }
body { margin: 0; min-height: 100dvh; background: var(--bg); color: var(--text); font: 14px/1.5 Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
button, input, select, textarea { font: inherit; }
button, select, input[type="checkbox"], input[type="radio"] { cursor: pointer; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible { outline: 3px solid rgba(139, 124, 246, .45); outline-offset: 2px; }
.prototype-banner { position: fixed; z-index: 1000; inset: 0 0 auto 0; height: var(--banner); display: flex; align-items: center; justify-content: center; background: #f6c344; color: #211900; font-size: 12px; font-weight: 800; letter-spacing: .07em; }
.icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: 0 0 auto; }
.app { min-height: 100dvh; padding-top: var(--banner); display: grid; grid-template-columns: var(--sidebar) minmax(0, 1fr); }
.sidebar { position: sticky; top: var(--banner); z-index: 30; height: calc(100dvh - var(--banner)); padding: 18px 12px; border-right: 1px solid var(--line); background: var(--surface); display: flex; flex-direction: column; }
.brand { display: flex; align-items: center; gap: 10px; padding: 2px 10px 20px; }
.brand-mark { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; background: linear-gradient(145deg, #8b7cf6, #38bdf8); color: white; font-weight: 900; box-shadow: 0 8px 22px rgba(113, 97, 232, .25); }
.brand strong { display: block; font-size: 16px; }
.brand small { color: var(--muted); }
.nav-label { margin: 8px 10px 6px; color: var(--subtle); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .12em; }
.nav { display: grid; gap: 4px; }
.nav button { min-height: 44px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--muted); display: flex; align-items: center; gap: 10px; text-align: left; transition: .2s ease; }
.nav button:hover { color: var(--text); background: var(--surface-2); }
.nav button[aria-current="page"] { color: var(--text); background: var(--primary-soft); box-shadow: inset 3px 0 var(--primary); }
.nav .count { margin-left: auto; min-width: 22px; padding: 1px 6px; border-radius: 999px; background: var(--surface-3); color: var(--text); text-align: center; font-size: 12px; }
.shift-card { margin-top: auto; padding: 12px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-2); }
.shift-card strong { display: block; }
.shift-card span { color: var(--muted); font-size: 12px; }
.shift-bar { margin-top: 9px; height: 5px; border-radius: 99px; background: var(--surface-3); overflow: hidden; }
.shift-bar i { display: block; width: 63%; height: 100%; background: var(--success); }
.main { min-width: 0; }
.topbar { position: sticky; top: var(--banner); z-index: 20; min-height: 64px; padding: 10px 20px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 94%, transparent); backdrop-filter: blur(12px); }
.site strong { display: block; font-size: 15px; }
.site span { color: var(--muted); font-size: 12px; }
.top-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
.btn { min-height: 40px; border: 1px solid var(--line); border-radius: 9px; padding: 8px 13px; background: var(--surface-2); color: var(--text); display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-weight: 750; text-decoration: none; transition: .2s ease; }
.btn:hover { border-color: var(--primary); }
.btn:disabled { cursor: not-allowed; opacity: .5; }
.btn-primary { background: var(--primary-strong); border-color: var(--primary-strong); color: white; }
.btn-success { background: #117a5d; border-color: #159271; color: white; }
.btn-danger { background: rgba(251,113,133,.1); border-color: rgba(251,113,133,.38); color: #ffb4c2; }
.btn-quiet { border-color: transparent; background: transparent; color: var(--muted); }
.icon-btn { width: 42px; padding: 0; }
.text-button { min-height: 32px; padding: 2px 6px; border: 0; background: transparent; color: #b8e5ff; text-decoration: underline; text-underline-offset: 3px; }
.select, .field, .textarea { width: 100%; min-height: 40px; padding: 8px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); }
.textarea { min-height: 80px; resize: vertical; }
.top-actions .select { width: auto; min-width: 150px; }
.content { padding: 18px; }
.view { display: none; animation: reveal .22s ease-out; }
.view.active { display: block; }
@keyframes reveal { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.page-head { margin-bottom: 15px; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.page-head h1 { margin: 0 0 4px; font-size: 24px; line-height: 1.25; }
.page-head p { margin: 0; color: var(--muted); }
.page-actions, .toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
.card-head { min-height: 52px; padding: 11px 14px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.card-head h2, .card-head h3 { margin: 0; font-size: 15px; }
.card-body { padding: 14px; }
.badge { display: inline-flex; align-items: center; gap: 5px; min-height: 24px; padding: 2px 8px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 12px; white-space: nowrap; }
.badge.success { color: #78edc2; border-color: rgba(52,211,153,.34); background: rgba(52,211,153,.1); }
.badge.warning { color: #ffe18a; border-color: rgba(251,191,36,.36); background: rgba(251,191,36,.1); }
.badge.danger { color: #ff9eb0; border-color: rgba(251,113,133,.4); background: rgba(251,113,133,.1); }
.badge.info { color: #a5e2ff; border-color: rgba(56,189,248,.35); background: rgba(56,189,248,.1); }
.badge.violet { color: #c9c1ff; border-color: rgba(139,124,246,.38); background: var(--primary-soft); }
.duty-grid { display: grid; grid-template-columns: 320px minmax(420px, 1fr) 330px; gap: 12px; min-height: calc(100dvh - 156px); }
.queue, .evidence, .action-panel { min-width: 0; overflow: hidden; }
.queue-head { padding: 10px; border-bottom: 1px solid var(--line); }
.queue-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; padding: 4px; border-radius: 9px; background: var(--surface-2); }
.queue-tabs button { min-height: 34px; border: 0; border-radius: 7px; background: transparent; color: var(--muted); }
.queue-tabs button.active { background: var(--surface-3); color: var(--text); }
.alert-list { padding: 8px; display: grid; gap: 7px; max-height: calc(100dvh - 265px); overflow-y: auto; }
.alert-card { width: 100%; padding: 11px; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--text); text-align: left; transition: .18s ease; }
.alert-card:hover { background: var(--surface-2); }
.alert-card.selected { background: var(--primary-soft); border-color: rgba(139,124,246,.45); }
.alert-card-top, .alert-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.alert-card h3 { margin: 8px 0 3px; font-size: 14px; }
.alert-card p { margin: 0; color: var(--muted); font-size: 12px; }
.alert-card-foot { margin-top: 9px; color: var(--subtle); font-size: 12px; }
.severity-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.evidence-summary { padding: 12px 14px; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); }
.evidence-summary h2 { margin: 0 0 5px; font-size: 18px; }
.evidence-summary p { margin: 0; color: var(--muted); }
.evidence-tabs { display: flex; gap: 4px; padding: 8px 12px 0; }
.evidence-tabs button { min-height: 36px; padding: 0 12px; border: 0; border-radius: 8px 8px 0 0; background: transparent; color: var(--muted); }
.evidence-tabs button.active { background: var(--surface-2); color: var(--text); }
.video-stage { position: relative; aspect-ratio: 16/9; margin: 0 12px 12px; background: #02060d; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; }
.video-stage svg { width: 100%; height: 100%; display: block; }
.video-controls { position: absolute; inset: auto 0 0; min-height: 44px; padding: 6px 10px; display: flex; align-items: center; gap: 9px; color: white; background: linear-gradient(transparent, rgba(0,0,0,.88)); }
.video-controls button { width: 34px; height: 34px; border: 0; border-radius: 7px; background: rgba(255,255,255,.08); color: white; }
.timeline-bar { flex: 1; height: 5px; border-radius: 99px; background: rgba(255,255,255,.22); overflow: hidden; }
.timeline-bar i { display: block; width: 54%; height: 100%; background: var(--primary); }
.fact-grid { margin: 0 12px 12px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.fact { padding: 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); }
.fact span { display: block; color: var(--muted); font-size: 11px; }
.fact strong { display: block; margin-top: 3px; font-size: 13px; font-variant-numeric: tabular-nums; }
.privacy-note { margin: 0 12px 12px; padding: 9px 10px; display: flex; gap: 8px; border-left: 3px solid var(--cyan); background: rgba(56,189,248,.08); color: var(--muted); font-size: 12px; }
.relation-panel { margin: 0 12px 12px; padding: 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
.relation-panel h3 { margin: 0 0 8px; font-size: 13px; }
.relation-list { display: grid; gap: 7px; }
.relation-item { min-height: 40px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--text); display: flex; align-items: center; justify-content: space-between; gap: 10px; text-align: left; }
.relation-item span { color: var(--muted); font-size: 12px; }
.action-scroll { max-height: calc(100dvh - 210px); overflow-y: auto; }
.ack-box { padding: 14px; border-bottom: 1px solid var(--line); }
.ack-box .btn { width: 100%; min-height: 48px; }
.ack-box p { margin: 9px 0 0; color: var(--muted); font-size: 12px; }
.ack-tools { margin-top: 8px; display: flex; justify-content: flex-end; }
.inline-alert { margin-top: 9px; padding: 9px 10px; border: 1px solid rgba(251,191,36,.4); border-radius: 8px; background: rgba(251,191,36,.09); color: #ffe8a8; font-size: 12px; }
.inline-alert.danger { border-color: rgba(251,113,133,.42); background: rgba(251,113,133,.09); color: #ffc3ce; }
.countdown { display: grid; grid-template-columns: auto 1fr; gap: 10px; align-items: center; padding: 12px; margin: 12px; border: 1px solid rgba(251,191,36,.28); border-radius: 10px; background: rgba(251,191,36,.07); }
.countdown strong { display: block; font-size: 20px; color: #ffe18a; font-variant-numeric: tabular-nums; }
.countdown span { color: var(--muted); font-size: 12px; }
.escalation { padding: 0 14px 14px; }
.escalation h3 { margin: 0 0 10px; font-size: 14px; }
.step { position: relative; padding: 0 0 16px 27px; }
.step::before { content: ""; position: absolute; left: 7px; top: 17px; bottom: 0; width: 1px; background: var(--line); }
.step:last-child::before { display: none; }
.step-dot { position: absolute; left: 0; top: 3px; width: 15px; height: 15px; border-radius: 50%; border: 3px solid var(--surface); background: var(--subtle); box-shadow: 0 0 0 1px var(--line); }
.step.done .step-dot { background: var(--success); }
.step.current .step-dot { background: var(--warning); }
.step strong { display: block; }
.step span { color: var(--muted); font-size: 12px; }
.delivery-facts { margin-top: 4px; display: grid; gap: 2px; }
.delivery-facts span { display: block; }
.disposition { padding: 14px; border-top: 1px solid var(--line); display: grid; gap: 9px; }
.disposition label { color: var(--muted); font-size: 12px; font-weight: 700; }
.disposition-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.grid { display: grid; gap: 12px; }
.stats { grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: 12px; }
.stat { padding: 15px; min-height: 110px; display: flex; flex-direction: column; justify-content: space-between; }
.stat span { color: var(--muted); }
.stat strong { font-size: 27px; font-variant-numeric: tabular-nums; }
.search { position: relative; min-width: 230px; }
.search .icon { position: absolute; left: 11px; top: 11px; color: var(--subtle); pointer-events: none; }
.search input { padding-left: 38px; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 870px; }
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; }
th { background: var(--surface-2); color: var(--muted); font-size: 12px; }
td { font-variant-numeric: tabular-nums; }
tr:last-child td { border-bottom: 0; }
tr:hover td { background: color-mix(in srgb, var(--primary-soft) 50%, transparent); }
.pager { min-height: 58px; padding: 10px 14px; border-top: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.pager-actions { display: flex; gap: 7px; }
.rule-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.rule-card { padding: 15px; }
.rule-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.rule-card h2 { margin: 12px 0 5px; font-size: 16px; }
.rule-card p { margin: 0 0 14px; color: var(--muted); }
.rule-card dl { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: 7px; color: var(--muted); }
.rule-card dd { margin: 0; color: var(--text); }
.rule-card-actions { margin-top: 14px; padding-top: 12px; display: flex; flex-wrap: wrap; gap: 7px; border-top: 1px solid var(--line); }
.rule-context { margin-bottom: 12px; padding: 11px 13px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid rgba(56,189,248,.34); border-radius: 10px; background: rgba(56,189,248,.08); }
.rule-context[hidden] { display: none; }
.rule-context p { margin: 0; color: var(--muted); }
.site-grid { grid-template-columns: minmax(260px,.72fr) minmax(520px,1.45fr); }
.site-list { padding: 8px; display: grid; gap: 7px; }
.site-item { width: 100%; min-height: 62px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); display: flex; justify-content: space-between; align-items: center; gap: 10px; text-align: left; }
.site-item.active { border-color: var(--primary); background: var(--primary-soft); }
.site-item small { display: block; color: var(--muted); }
.policy-note { margin: 12px 14px 0; padding: 10px 12px; border-left: 3px solid var(--warning); background: rgba(251,191,36,.08); color: var(--muted); }
.context-strip { margin-bottom: 12px; padding: 11px 13px; border: 1px solid rgba(56,189,248,.34); border-radius: 10px; background: rgba(56,189,248,.08); display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.context-strip p { margin: 0; color: var(--muted); }
.linked-zone { grid-column: 1/-1; padding: 11px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
.linked-zone strong, .linked-zone small { display: block; }
.linked-zone small { color: var(--muted); }
.workflow-note { grid-column: 1/-1; margin: 0; padding: 10px 11px; border-left: 3px solid var(--primary); background: var(--primary-soft); color: var(--muted); font-size: 12px; }
.version-diff { display: grid; gap: 8px; }
.version-diff article { padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); }
.version-diff article strong { display: block; }
.chain-subnav { margin-bottom: 12px; padding: 4px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface); }
.chain-subnav button { min-height: 44px; border: 0; border-radius: 8px; background: transparent; color: var(--muted); font-weight: 750; }
.chain-subnav button.active { background: var(--primary-soft); color: var(--text); box-shadow: inset 0 0 0 1px rgba(139,124,246,.36); }
.chain-panel { display: none; }
.chain-panel.active { display: block; }
.chain-grid { grid-template-columns: minmax(0, 1.4fr) minmax(300px, .8fr); }
.chain-row { padding: 13px 14px; display: grid; grid-template-columns: 40px 1fr auto; gap: 11px; align-items: center; border-bottom: 1px solid var(--line); }
.chain-row:last-child { border-bottom: 0; }
.chain-num { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; background: var(--primary-soft); color: #cfc8ff; font-weight: 800; }
.chain-row small { color: var(--muted); }
.target-ref { display: inline-block; margin-top: 3px; color: #b8e5ff; font: 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
.resolve-card { padding: 14px; }
.resolve-card h2 { margin: 0 0 4px; font-size: 15px; }
.resolve-card > p { margin: 0 0 12px; color: var(--muted); }
.resolve-list { display: grid; gap: 8px; }
.resolve-item { padding: 10px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); }
.resolve-item span, .resolve-item small { display: block; color: var(--muted); }
.resolve-item strong { display: block; margin: 2px 0; }
.roster-week { padding: 12px; display: grid; grid-template-columns: repeat(7, minmax(125px, 1fr)); gap: 8px; overflow-x: auto; }
.roster-day { min-width: 125px; padding: 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); }
.roster-day.current { border-color: var(--primary); background: var(--primary-soft); }
.roster-day > strong { display: block; margin-bottom: 8px; }
.shift-slot { padding: 8px 0; border-top: 1px solid var(--line); }
.shift-slot:first-of-type { border-top: 0; }
.shift-slot span, .shift-slot small { display: block; color: var(--muted); font-size: 12px; }
.shift-slot strong { display: block; margin: 2px 0; font-size: 13px; }
.object-note { padding: 11px 14px; border-top: 1px solid var(--line); color: var(--muted); }
.contact-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 12px; }
.contact-summary .stat { min-height: 96px; }
.chart-grid { grid-template-columns: 1.35fr .65fr; }
.chart { padding: 16px; }
.chart h2 { margin: 0; font-size: 15px; }
.chart p { margin: 4px 0 14px; color: var(--muted); }
.bars { height: 220px; display: flex; align-items: end; gap: 16px; padding: 10px 6px 26px; border-bottom: 1px solid var(--line); }
.bar { position: relative; flex: 1; min-width: 24px; height: var(--h); border-radius: 7px 7px 0 0; background: linear-gradient(180deg, var(--primary), #4f46a8); }
.bar span { position: absolute; inset: calc(100% + 5px) 0 auto; text-align: center; color: var(--muted); font-size: 11px; }
.donut { width: 170px; height: 170px; margin: 18px auto; border-radius: 50%; background: conic-gradient(var(--success) 0 61%, var(--warning) 61% 83%, var(--danger) 83%); position: relative; }
.donut::after { content: "83%\A 24h 内闭环"; white-space: pre; position: absolute; inset: 27px; border-radius: 50%; display: grid; place-items: center; text-align: center; background: var(--surface); color: var(--text); font-weight: 800; }
.legend { display: flex; flex-wrap: wrap; gap: 10px; color: var(--muted); font-size: 12px; }
.legend i { display: inline-block; width: 9px; height: 9px; margin-right: 5px; border-radius: 2px; }
.metric-note { margin: 12px 0 0; padding: 10px 12px; border-left: 3px solid var(--cyan); background: rgba(56,189,248,.08); color: var(--muted); }
.state-layer { display: none; min-height: 420px; place-items: center; padding: 24px; border: 1px dashed var(--line); border-radius: var(--radius); background: var(--surface); text-align: center; }
.state-layer.show { display: grid; }
.state-layer .icon { width: 40px; height: 40px; color: var(--primary); }
.state-layer h2 { margin: 12px 0 4px; font-size: 18px; }
.state-layer p { margin: 0 0 14px; max-width: 460px; color: var(--muted); }
.skeleton { width: min(460px, 90%); display: grid; gap: 10px; }
.skeleton i { height: 15px; border-radius: 6px; background: linear-gradient(90deg, var(--surface-3), var(--line), var(--surface-3)); background-size: 200% 100%; animation: shimmer 1.4s infinite; }
.skeleton i:nth-child(2) { width: 72%; }
@keyframes shimmer { to { background-position: -200% 0; } }
dialog { width: min(610px, calc(100% - 28px)); max-height: 88dvh; padding: 0; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); color: var(--text); box-shadow: 0 24px 80px rgba(0,0,0,.5); }
#ruleDialog, #eventDialog, #handoverDialog, #rollbackDialog, #escalationDialog, #scheduleDialog, #substituteDialog, #contactDialog { width: min(760px, calc(100% - 28px)); }
dialog::backdrop { background: rgba(4,7,15,.68); backdrop-filter: blur(4px); }
.dialog-head, .dialog-foot { padding: 14px 18px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.dialog-head h2 { margin: 0; font-size: 17px; }
.dialog-body { padding: 18px; overflow: auto; }
.dialog-foot { border: 0; border-top: 1px solid var(--line); justify-content: flex-end; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-field { display: grid; gap: 6px; }
.form-field.full { grid-column: 1/-1; }
.form-field label { color: var(--muted); font-size: 12px; font-weight: 700; }
.radio-card { display: block; margin-bottom: 9px; padding: 11px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); }
.toast { position: fixed; right: 20px; bottom: 20px; z-index: 1100; max-width: min(420px, calc(100% - 40px)); padding: 12px 14px; border: 1px solid rgba(52,211,153,.35); border-radius: 10px; background: #0b2c25; color: #d9fff3; box-shadow: var(--shadow); transform: translateY(20px); opacity: 0; pointer-events: none; transition: .2s ease; }
.toast.show { transform: none; opacity: 1; }
.muted { color: var(--muted); }
.mono { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
.mobile-nav { display: none; }
.mobile-manage { display: none; }
@media (max-width: 1240px) {
:root { --sidebar: 78px; }
.brand-copy, .nav span:not(.count), .nav-label, .shift-card { display: none; }
.brand { justify-content: center; padding-inline: 0; }
.nav button { justify-content: center; padding: 0; }
.nav .count { position: absolute; margin: -24px 0 0 25px; }
.duty-grid { grid-template-columns: 285px minmax(390px, 1fr); }
.action-panel { grid-column: 1/-1; }
.action-scroll { max-height: none; display: grid; grid-template-columns: repeat(3, 1fr); }
.disposition { border-top: 0; border-left: 1px solid var(--line); }
}
@media (max-width: 850px) {
:root { --sidebar: 0px; }
.app { display: block; padding-bottom: 66px; }
.sidebar { display: none; }
.topbar { padding: 8px 12px; }
.site span, .top-actions .select { display: none; }
.mobile-manage { display: inline-flex; min-width: 44px; }
.content { padding: 12px; }
.btn, .select, .field, .textarea { min-height: 44px; }
.icon-btn { min-width: 44px; }
.duty-grid, .chain-grid, .chart-grid { grid-template-columns: 1fr; }
.alert-list, .action-scroll { max-height: none; }
.action-scroll { display: block; }
.disposition { border-left: 0; border-top: 1px solid var(--line); }
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.site-grid { grid-template-columns: 1fr; }
.roster-week { grid-template-columns: repeat(2, minmax(145px, 1fr)); }
.rule-grid { grid-template-columns: 1fr 1fr; }
.fact-grid { grid-template-columns: repeat(2, 1fr); }
.pager { align-items: flex-start; flex-direction: column; }
.mobile-nav { position: fixed; z-index: 50; left: 8px; right: 8px; bottom: 8px; display: grid; grid-template-columns: repeat(5, 1fr); padding: 5px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
.mobile-nav button { min-height: 48px; border: 0; border-radius: 9px; background: transparent; color: var(--muted); display: grid; place-items: center; font-size: 11px; }
.mobile-nav button.active { color: #c9c1ff; background: var(--primary-soft); }
}
@media (max-width: 560px) {
.page-head { display: grid; }
.stats, .rule-grid { grid-template-columns: 1fr; }
.chain-subnav { grid-template-columns: 1fr; }
.contact-summary { grid-template-columns: 1fr; }
.roster-week { grid-template-columns: 1fr; }
.form-grid { grid-template-columns: 1fr; }
.form-field.full { grid-column: auto; }
.video-stage { margin-inline: 8px; }
.fact-grid { margin-inline: 8px; }
.evidence-summary { display: grid; }
.disposition-actions { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } }
</style>
</head>
<body>
<svg aria-hidden="true" width="0" height="0" style="position:absolute">
<symbol id="i-inbox" viewBox="0 0 24 24"><path d="M4 4h16v16H4Z"/><path d="m4 14 5-1 2 3h2l2-3 5 1"/></symbol>
<symbol id="i-event" viewBox="0 0 24 24"><path d="M6 3h12v18H6z"/><path d="M9 8h6m-6 4h6m-6 4h4"/></symbol>
<symbol id="i-rule" viewBox="0 0 24 24"><path d="M4 6h7m6 0h3M4 12h3m6 0h7M4 18h9m6 0h1"/><circle cx="14" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="16" cy="18" r="2"/></symbol>
<symbol id="i-phone" viewBox="0 0 24 24"><path d="M5 4h4l2 5-3 2a16 16 0 0 0 5 5l2-3 5 2v4c-8 2-17-7-15-15Z"/></symbol>
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M4 20V9m6 11V4m6 16v-7m4 7H2"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="m12 3 8 3v6c0 5-3 8-8 9-5-1-8-4-8-9V6Z"/><path d="m9 12 2 2 4-5"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></symbol>
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9"/><path d="M10 21h4"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 3 2 20h20Z"/><path d="M12 9v4m0 3v.01"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="m8 5 11 7-11 7Z"/></symbol>
<symbol id="i-expand" viewBox="0 0 24 24"><path d="M8 3H3v5m13-5h5v5M8 21H3v-5m13 5h5v-5"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 3v12m0 0 5-5m-5 5-5-5M4 20h16"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v6l4 2"/></symbol>
<symbol id="i-more" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></symbol>
</svg>
<div class="prototype-banner">PROTOTYPE - 仅供枚举交互,非实现依据</div>
<div class="app">
<aside class="sidebar" aria-label="Bell 主导航">
<div class="brand"><div class="brand-mark">Y</div><div class="brand-copy"><strong>YoVision</strong><small>Bell · Duty Console</small></div></div>
<div class="nav-label">值班与业务</div>
<nav class="nav">
<button data-view="duty" aria-current="page"><svg class="icon"><use href="#i-inbox"/></svg><span>值班台</span><span class="count">6</span></button>
<button data-view="events"><svg class="icon"><use href="#i-event"/></svg><span>事件中心</span></button>
<button data-view="rules"><svg class="icon"><use href="#i-rule"/></svg><span>规则策略</span></button>
<button data-view="chains"><svg class="icon"><use href="#i-phone"/></svg><span>升级链</span></button>
<button data-view="reports"><svg class="icon"><use href="#i-chart"/></svg><span>运营报表</span></button>
</nav>
<div class="nav-label">管理</div>
<nav class="nav">
<button data-view="sites"><svg class="icon"><use href="#i-rule"/></svg><span>站点与 Area</span></button>
<button data-view="audit"><svg class="icon"><use href="#i-shield"/></svg><span>审计日志</span></button>
</nav>
<div class="shift-card"><strong>安保值班组 · 白班</strong><span>08:00–20:00 · 4 人在线</span><div class="shift-bar"><i></i></div></div>
</aside>
<main class="main" id="main-content" tabindex="-1">
<header class="topbar">
<div class="site"><strong>青藤教育集团 · 青藤寄宿学校</strong><span>当前账号:林工 · 租户管理员 · Asia/Shanghai</span></div>
<div class="top-actions">
<select class="select" id="stateSelect" aria-label="原型状态演示"><option value="normal">正常状态</option><option value="loading">加载中</option><option value="weak">弱网 / 证据失败</option><option value="empty">队列为空</option><option value="denied">无权限</option><option value="expired">会话已过期</option></select>
<button class="btn mobile-manage" id="mobileManage" aria-haspopup="dialog">管理</button>
<button class="btn icon-btn" id="themeToggle" aria-label="切换明暗主题" title="切换明暗主题"><svg class="icon"><use href="#i-sun"/></svg></button>
<button class="btn icon-btn" aria-label="查看通知" title="查看通知"><svg class="icon"><use href="#i-bell"/></svg></button>
</div>
</header>
<div class="content">
<section class="state-layer" id="stateLayer" aria-live="polite"></section>
<section class="view active" data-page="duty">
<div class="page-head"><div><h1>实时值班台</h1><p>先确认高危预警,再查看证据与升级状态;重复投递不会生成重复处置。</p></div><div class="page-actions"><span class="badge success">值班组在线</span><button class="btn" id="handoverButton">发起交接班</button><button class="btn" id="muteButton">限时静默</button></div></div>
<div class="duty-grid">
<section class="card queue" aria-label="待处置预警队列">
<div class="card-head"><h2>待处置预警</h2><span class="badge danger">6 个未 ack</span></div>
<div class="queue-head"><div class="queue-tabs" aria-label="预警队列筛选"><button class="active" aria-pressed="true">全部 9</button><button aria-pressed="false">高危 3</button><button aria-pressed="false">我的 2</button></div></div>
<div class="alert-list" id="alertList"></div>
</section>
<section class="card evidence" aria-label="事件事实与证据">
<div class="evidence-summary"><div><span class="badge danger" id="selectedSeverity">高危 · S1</span><h2 id="selectedTitle">危险区域闯入</h2><p id="selectedMeta">北围墙 01 · 23:14:08 · EVT-01J8…7A2</p></div><button class="btn icon-btn" aria-label="更多事件操作"><svg class="icon"><use href="#i-more"/></svg></button></div>
<div class="evidence-tabs" aria-label="证据类型筛选"><button class="active" aria-pressed="true">视频片段</button><button aria-pressed="false">抓拍 2</button><button aria-pressed="false">结构化事实</button></div>
<div class="video-stage" id="videoStage">
<svg viewBox="0 0 960 540" role="img" aria-label="北围墙 01 事件视频模拟画面,匿名目标进入危险区域">
<defs><linearGradient id="bellSky" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#102337"/><stop offset="1" stop-color="#060a11"/></linearGradient><pattern id="bellScan" width="8" height="8" patternUnits="userSpaceOnUse"><path d="M0 8h8" stroke="#fff" stroke-opacity=".035"/></pattern></defs>
<rect width="960" height="540" fill="url(#bellSky)"/><path d="M0 340 180 260 355 316 520 232 690 306 830 230 960 285V540H0Z" fill="#142638"/><rect y="414" width="960" height="126" fill="#111820"/><path d="M0 416h960M0 474h960" stroke="#6d8496" stroke-opacity=".48"/><path d="M150 305v235m250-240v240m280-255v255m170-280v280" stroke="#758b9d" stroke-width="4"/><polygon points="92,362 305,318 558,342 525,480 126,478" fill="#fb7185" fill-opacity=".15" stroke="#fb7185" stroke-width="4" stroke-dasharray="10 7"/><rect x="354" y="330" width="63" height="148" fill="none" stroke="#fbbf24" stroke-width="4"/><text x="354" y="319" fill="#ffe18a" font-size="17">person · 0.92</text><circle cx="385" cy="310" r="24" fill="none" stroke="#fbbf24" stroke-width="4"/><rect width="960" height="540" fill="url(#bellScan)"/><text x="26" y="38" fill="#d6e8f5" font-size="18">北围墙 01 · 事件片段</text><text x="26" y="66" fill="#91a6b8" font-size="14">事发前 8s · 事发后 12s · 匿名目标</text>
</svg>
<div class="video-controls"><button aria-label="播放片段"><svg class="icon"><use href="#i-play"/></svg></button><span class="mono">00:11 / 00:20</span><div class="timeline-bar"><i></i></div><button aria-label="全屏查看"><svg class="icon"><use href="#i-expand"/></svg></button><button aria-label="下载授权证据"><svg class="icon"><use href="#i-download"/></svg></button></div>
</div>
<div class="fact-grid">
<div class="fact"><span>规则</span><strong id="factRule">非授权人员进入</strong></div><div class="fact"><span>置信度</span><strong>0.92</strong></div><div class="fact"><span>持续时间</span><strong>8.4s</strong></div><div class="fact"><span>证据状态</span><strong style="color:var(--success)">已校验</strong></div>
</div>
<div class="relation-panel"><h3>关联事件 · <span id="relatedEventCount">2</span></h3><div class="relation-list" id="relatedEvents"></div></div>
<div class="privacy-note"><svg class="icon"><use href="#i-lock"/></svg><span>仅显示与本事件关联的 20 秒证据。常态录像仍在客户 NVR,本页不暴露流地址或摄像头凭据。</span></div>
</section>
<aside class="card action-panel" aria-label="预警确认与处置">
<div class="card-head"><h2>处置与升级</h2><span class="badge warning" id="ackState">等待 ack</span></div>
<div class="action-scroll">
<div class="ack-box"><button class="btn btn-primary" id="ackButton"><svg class="icon"><use href="#i-check"/></svg>确认接手</button><p id="ackHelp">一次点击确认;并发确认时会显示实际接手人,不覆盖对方结果。</p><div class="ack-tools"><button class="text-button" id="simulateAckConflict">演示另一值班员抢先 ack</button></div><div class="inline-alert" id="ackConflict" role="alert" hidden></div></div>
<div><div class="countdown"><svg class="icon" style="color:var(--warning);width:26px;height:26px"><use href="#i-clock"/></svg><div><strong id="countdown">01:42</strong><span>未 ack 将升级到校级负责人</span></div></div>
<div class="escalation"><h3>投递时间线</h3>
<div class="step done"><i class="step-dot"></i><strong>值班室 Web + 本地声光</strong><div class="delivery-facts"><span>已发出 23:14:10</span><span>已送达 23:14:11</span><span>已看到 23:14:31</span></div></div>
<div class="step current"><i class="step-dot"></i><strong>值班员短信 · 可重试</strong><div class="delivery-facts"><span>已发出 23:14:13</span><span style="color:var(--warning)">未收到送达回执 · 已等待 42 秒</span><span>通道不支持“已看到” · 重试 2/3,23:15:25</span></div></div>
<div class="step"><i class="step-dot"></i><strong>校级负责人语音</strong><div class="delivery-facts"><span>预计 23:16:10 · 尚未发送</span><span>号码无效将标记最终失败,不继续伪装送达</span></div></div>
</div>
</div>
<div class="disposition"><label for="outcome">处置结果</label><select class="select" id="outcome"><option value="">待选择</option><option>已现场核查,无人员受伤</option><option>已通知安保人员处理</option><option>已转交校方负责人</option></select><label for="note">处置备注</label><textarea class="textarea" id="note" placeholder="记录已采取的动作,不改写原始事件事实"></textarea><div class="disposition-actions"><button class="btn btn-danger" id="falsePositive">标记误报</button><button class="btn btn-success" id="completeAlert">完成处置</button></div></div>
</div>
</aside>
</div>
</section>
<section class="view" data-page="events">
<div class="page-head"><div><h1>事件中心</h1><p>事件是不可变事实;预警与处置结果作为关联记录单独展示。</p></div><div class="page-actions"><button class="btn"><svg class="icon"><use href="#i-download"/></svg>导出筛选结果</button></div></div>
<div class="grid stats">
<article class="card stat"><span>今日事件</span><strong>47</strong><span class="muted">较昨日 -8%</span></article><article class="card stat"><span>已触发预警</span><strong>18</strong><span class="muted">规则命中后独立创建</span></article><article class="card stat"><span>误报反馈</span><strong>5</strong><span class="muted">进入标注队列</span></article><article class="card stat"><span>证据可用率</span><strong>97.9%</strong><span class="muted">1 个片段可重试</span></article>
</div>
<article class="card"><div class="card-head"><div class="toolbar"><div class="search"><svg class="icon"><use href="#i-search"/></svg><input class="field" aria-label="搜索事件" placeholder="事件编号、点位或规则"></div><select class="select" aria-label="严重度筛选"><option>全部严重度</option><option>S1 高危</option><option>S2 重要</option></select><select class="select" aria-label="结果筛选"><option>全部结果</option><option>已处置</option><option>误报</option></select></div><span class="muted">共 47 条</span></div><div class="table-wrap"><table><thead><tr><th>事件编号</th><th>时间</th><th>点位</th><th>规则</th><th>事实</th><th>预警</th><th>结果</th><th>证据</th></tr></thead><tbody id="eventRows"></tbody></table></div><div class="pager"><span id="eventPageSummary">第 1 / 16 页 · 每页 3 条</span><div class="pager-actions"><button class="btn" id="eventPrev">上一页</button><button class="btn" id="eventNext">下一页</button></div></div></article>
</section>
<section class="view" data-page="rules">
<div class="rule-context" id="ruleContext" hidden><p><strong>已带入摄像头与区域上下文:</strong><span id="ruleContextCamera">北围墙 01</span> · <span id="ruleContextZone">ZONE-007</span>,可继续完成规则草稿。</p><button class="btn btn-primary" id="continueRuleDraft">继续配置</button></div>
<div class="page-head"><div><h1>规则策略</h1><p>Bell 管理场景、范围、时段、持续时间、试运行与发布;检测区域来自对应摄像头的版本化空间配置。</p></div><div class="page-actions"><button class="btn btn-primary" id="openRuleDialog">新建规则</button></div></div>
<div class="grid rule-grid">
<article class="card rule-card"><div class="rule-card-top"><span class="badge success" id="publishedRuleBadge">正式生效 · v7</span><button class="btn btn-quiet icon-btn" aria-label="更多操作"><svg class="icon"><use href="#i-more"/></svg></button></div><h2>非授权人员进入</h2><p>危险区域 × 全天 × 持续 3 秒</p><dl><dt>覆盖范围</dt><dd>4 个点位</dd><dt>检测区域</dt><dd>北围墙危险区域 v7</dd><dt>继承来源</dt><dd>站点模板</dd><dt>今日命中</dt><dd>8</dd></dl><div class="rule-card-actions"><button class="btn" data-edit-rule>编辑规则</button><button class="btn" id="openRollback">版本历史 / 回滚</button><a class="btn" href="../sense/index.html?view=device-detail&amp;camera=CAM-S2-001&amp;zone=ZONE-007&amp;return_to=bell-rule">校准检测区域</a></div></article>
<article class="card rule-card"><div class="rule-card-top"><span class="badge warning" id="trialBadge">试运行</span><button class="btn btn-quiet icon-btn" aria-label="更多操作"><svg class="icon"><use href="#i-more"/></svg></button></div><h2>区域内聚集</h2><p>人数 ≥ 6 × 持续 20 秒 × 夜间</p><dl><dt>覆盖范围</dt><dd>3 个点位</dd><dt>检测区域</dt><dd>3 个区域版本</dd><dt>继承来源</dt><dd>设备覆盖</dd><dt>样本命中</dt><dd>14</dd></dl><div class="rule-card-actions"><button class="btn" data-edit-rule>查看试运行</button><button class="btn btn-primary" id="publishTrialRule">正式发布</button></div></article>
<article class="card rule-card"><div class="rule-card-top"><span class="badge violet">草稿</span><button class="btn btn-quiet icon-btn" aria-label="更多操作"><svg class="icon"><use href="#i-more"/></svg></button></div><h2>方向越线</h2><p>警戒线 A → B × 上学时段</p><dl><dt>覆盖范围</dt><dd>1 个点位</dd><dt>检测区域</dt><dd>宿舍东通道 · 警戒线 v3</dd><dt>继承来源</dt><dd>设备草稿</dd><dt>最近修改</dt><dd>2h 前</dd></dl><div class="rule-card-actions"><button class="btn" data-edit-rule>继续编辑</button><a class="btn" href="../sense/index.html?view=device-detail&amp;camera=CAM-S2-011&amp;zone=ZONE-011&amp;return_to=bell-rule">查看警戒线</a></div></article>
</div>
</section>
<section class="view" data-page="chains">
<div class="page-head"><div><h1>升级链</h1><p>联系人、值班排班和升级策略统一设计、分对象管理;策略引用动态目标,不写死手机号。</p></div><div class="page-actions"><span class="badge success">当前排班覆盖完整</span></div></div>
<div class="chain-subnav" aria-label="升级链二级模块"><button class="active" data-chain-section="strategy" aria-pressed="true">升级策略</button><button data-chain-section="roster" aria-pressed="false">值班与排班</button><button data-chain-section="contacts" aria-pressed="false">联系人和通道</button></div>
<section class="chain-panel active" data-chain-panel="strategy">
<div class="grid chain-grid"><article class="card"><div class="card-head"><div><h2>校园高危事件 · 默认链</h2><span class="muted">每一步引用类型化目标,解析后才创建投递</span></div><span class="badge success">已发布 v4</span></div>
<div class="chain-row"><div class="chain-num">1</div><div><strong>值班室 Web + 本地声光</strong><small>立即 · 站点局域网内</small><span class="target-ref">team:DUTY-ROOM</span></div><span class="badge success">固定值班组</span></div>
<div class="chain-row"><div class="chain-num">2</div><div><strong>当前排班值班员 · 短信</strong><small>立即 · 送达后等待 2 分钟</small><span class="target-ref">on_call_schedule:SCH-CAMPUS-SECURITY@v12</span></div><span class="badge info">动态排班</span></div>
<div class="chain-row"><div class="chain-num">3</div><div><strong>校级负责人 · 语音</strong><small>未 ack 时升级 · 等待 3 分钟</small><span class="target-ref">person:USR-SCHOOL-LEAD</span></div><span class="badge warning">指定人员</span></div>
<div class="object-note">升级策略只保存目标引用和通道偏好,不复制联系人号码或轮换字段。</div>
</article><article class="card resolve-card"><div class="card-head" style="padding:0 0 11px;margin-bottom:12px"><div><h2>目标解析预览</h2><span class="muted">事件时间 2026-08-04 23:14 · Asia/Shanghai</span></div><button class="btn" id="editEscalation">编辑策略</button></div><div class="resolve-list"><div class="resolve-item"><span>排班目标</span><strong>校园安保值班表 · v12</strong><small class="mono">SCH-CAMPUS-SECURITY</small></div><div class="resolve-item"><span>当前解析结果</span><strong id="resolvedPerson">王值班员 · 晚班</strong><small>短信已验证 · 语音已验证</small></div><div class="resolve-item"><span>投递快照</span><strong>23:14:13 已固化</strong><small>收件人、通道、排班 v12;后续编辑不改写本次事实</small></div></div></article></div>
<div class="grid chain-grid" style="margin-top:12px"><article class="card"><div class="card-head"><h2>解析与版本规则</h2><span class="badge info">不可变历史</span></div><div class="card-body"><p class="muted">每次投递创建时按站点时区解析当时生效的排班版本,并保存实际收件人、通道与版本快照。未来排班版本只影响其生效时间后的新投递。</p></div></article><article class="card"><div class="card-head"><h2>静默规则</h2><span class="badge info">无永久选项</span></div><div class="card-body"><label for="muteDuration" class="muted">维护窗口</label><select id="muteDuration" class="select" style="margin-top:6px"><option>30 分钟</option><option>1 小时</option><option>2 小时</option><option>4 小时(上限)</option></select><p class="muted">静默仅影响选定规则和点位,到时自动恢复;S1 事件不可静默。</p><button class="btn">创建维护窗口</button></div></article></div>
</section>
<section class="chain-panel" data-chain-panel="roster">
<div class="grid stats"><article class="card stat"><span>当前排班</span><strong id="scheduleVersion">v12</strong><span class="muted">已发布 · 08-01 生效</span></article><article class="card stat"><span>站点时区</span><strong style="font-size:18px">Asia/Shanghai</strong><span class="muted">跨午夜班次按站点时间</span></article><article class="card stat"><span>当前值班</span><strong style="font-size:18px">王值班员</strong><span class="muted">晚班 · 至明日 08:00</span></article><article class="card stat"><span>未来 14 天覆盖</span><strong>100%</strong><span class="muted">0 空档 · 0 重叠</span></article></div>
<article class="card"><div class="card-head"><div><h2>校园安保值班表</h2><span class="muted">周轮换 · 白班 08:00–20:00 / 晚班 20:00–08:00</span></div><div class="page-actions"><button class="btn" id="simulateRosterConflict">演示冲突检查</button><button class="btn" id="openSubstitute">临时替班</button><button class="btn btn-primary" id="openSchedulePublish">发布新版本</button></div></div><div class="inline-alert danger" id="rosterConflict" role="alert" hidden style="margin:12px 12px 0"></div><div class="roster-week">
<div class="roster-day current"><strong>今天 · 周二</strong><div class="shift-slot"><span>白班</span><strong>林值班员</strong><small>08:00–20:00</small></div><div class="shift-slot"><span>晚班</span><strong id="currentNightShift">王值班员</strong><small>20:00–08:00</small></div></div>
<div class="roster-day"><strong>周三</strong><div class="shift-slot"><span>白班</span><strong>赵值班员</strong><small>08:00–20:00</small></div><div class="shift-slot"><span>晚班</span><strong id="wednesdayNightShift">周值班员</strong><small>20:00–08:00</small></div></div>
<div class="roster-day"><strong>周四</strong><div class="shift-slot"><span>白班</span><strong>林值班员</strong><small>08:00–20:00</small></div><div class="shift-slot"><span>晚班</span><strong>王值班员</strong><small>20:00–08:00</small></div></div>
<div class="roster-day"><strong>周五</strong><div class="shift-slot"><span>白班</span><strong>赵值班员</strong><small>08:00–20:00</small></div><div class="shift-slot"><span>晚班</span><strong>周值班员</strong><small>20:00–08:00</small></div></div>
<div class="roster-day"><strong>周六</strong><div class="shift-slot"><span>白班</span><strong>林值班员</strong><small>08:00–20:00</small></div><div class="shift-slot"><span>晚班</span><strong>王值班员</strong><small>20:00–08:00</small></div></div>
<div class="roster-day"><strong>周日</strong><div class="shift-slot"><span>白班</span><strong>赵值班员</strong><small>08:00–20:00</small></div><div class="shift-slot"><span>晚班</span><strong>周值班员</strong><small>20:00–08:00</small></div></div>
<div class="roster-day"><strong>下周一</strong><div class="shift-slot"><span>白班</span><strong>林值班员</strong><small>08:00–20:00</small></div><div class="shift-slot"><span>晚班</span><strong>王值班员</strong><small>20:00–08:00</small></div></div>
</div><div class="object-note"><strong>边界:</strong>临时替班进入新排班草稿并经发布后影响未来班次;值班交接只转移进行中 Alert 的责任,不会暗改这里的轮换。</div></article>
<article class="card" style="margin-top:12px"><div class="card-head"><h2>版本与例外</h2><span class="badge info">全量审计</span></div><div class="table-wrap"><table><thead><tr><th>版本</th><th>生效时间</th><th>变更</th><th>状态</th><th>发布人</th></tr></thead><tbody><tr><td>v12</td><td>2026-08-01 00:00</td><td>暑期周轮换</td><td><span class="badge success">当前生效</span></td><td>林工</td></tr><tr><td>v11</td><td>2026-07-01 00:00</td><td>赵值班员加入白班组</td><td><span class="badge">历史</span></td><td>周管理员</td></tr></tbody></table></div></article>
</section>
<section class="chain-panel" data-chain-panel="contacts">
<div class="grid contact-summary"><article class="card stat"><span>联系人</span><strong id="contactCount">6</strong><span class="muted">4 名值班员 · 2 名负责人</span></article><article class="card stat"><span>已验证通道</span><strong>11 / 12</strong><span class="muted">1 个语音号码待验证</span></article><article class="card stat"><span>被排班引用</span><strong>4</strong><span class="muted">删除前必须解除引用</span></article></div>
<article class="card"><div class="card-head"><div><h2>联系人和通知通道</h2><span class="muted">只维护身份、角色、值班组和通道;班次与轮换在排班模块维护</span></div><button class="btn btn-primary" id="openContact">新建联系人</button></div><div class="table-wrap"><table><thead><tr><th>联系人</th><th>角色</th><th>值班组</th><th>通知通道</th><th>排班引用</th><th>状态</th><th>操作</th></tr></thead><tbody><tr><td><strong>王值班员</strong><br><span class="mono muted">USR-DUTY-WANG</span></td><td>值班员</td><td>校园安保组</td><td>短信 ✓ · 语音 ✓ · Web ✓</td><td>SCH-CAMPUS-SECURITY</td><td><span class="badge success">可用</span></td><td><button class="btn" data-edit-contact="wang">编辑</button></td></tr><tr><td><strong>林值班员</strong><br><span class="mono muted">USR-DUTY-LIN</span></td><td>值班员</td><td>校园安保组</td><td>短信 ✓ · Web ✓</td><td>SCH-CAMPUS-SECURITY</td><td><span class="badge success">可用</span></td><td><button class="btn" data-edit-contact="lin">编辑</button></td></tr><tr><td><strong>校级负责人</strong><br><span class="mono muted">USR-SCHOOL-LEAD</span></td><td>站点负责人</td><td>校级负责人组</td><td>短信 ✓ · 语音待验证</td><td>升级策略 v4</td><td><span class="badge warning">部分可用</span></td><td><button class="btn" data-edit-contact="lead">编辑</button></td></tr></tbody></table></div><div class="object-note">联系人通道变更只影响后续新投递;已创建投递保留当时的收件人和通道快照。联系人被排班或策略引用时不能直接删除。</div></article>
</section>
</section>
<section class="view" data-page="reports">
<div class="page-head"><div><h1>运营报表</h1><p>算法效果和系统 SLA 分开统计;图表同时提供表格导出。</p></div><div class="page-actions"><button class="btn"><svg class="icon"><use href="#i-download"/></svg>导出 CSV</button></div></div>
<div class="grid chart-grid"><article class="card chart"><h2>近 7 日预警量</h2><p>按站点日期汇总;可切换到每路每天误报数。</p><div class="bars" role="img" aria-label="近七日预警量依次为 18、22、15、31、27、24、19"><div class="bar" style="--h:58%"><span>周一</span></div><div class="bar" style="--h:70%"><span>周二</span></div><div class="bar" style="--h:48%"><span>周三</span></div><div class="bar" style="--h:100%"><span>周四</span></div><div class="bar" style="--h:87%"><span>周五</span></div><div class="bar" style="--h:77%"><span>周六</span></div><div class="bar" style="--h:61%"><span>周日</span></div></div></article><article class="card chart"><h2>处置结果</h2><p>本月已闭环事件。</p><div class="donut" role="img" aria-label="正常处置 61%,误报 22%,升级未闭环 17%"></div><div class="legend"><span><i style="background:var(--success)"></i>正常处置 61%</span><span><i style="background:var(--warning)"></i>误报 22%</span><span><i style="background:var(--danger)"></i>未闭环 17%</span></div></article></div>
<article class="card" style="margin-top:12px"><div class="card-head"><div><h2>规则验收效果</h2><span class="muted">冻结样本窗口:2026-07-01~2026-07-31 · 现场复核集 v3</span></div><button class="btn">导出验收附件</button></div><div class="table-wrap"><table><thead><tr><th>规则版本</th><th>样本窗口</th><th>正样本 / 漏检</th><th>召回率</th><th>通道天数</th><th>误报数</th><th>每路每天误报数</th></tr></thead><tbody><tr><td>非授权人员进入 · v7</td><td>07-01~07-31</td><td>186 / 8</td><td><strong>95.7%</strong></td><td>496</td><td>19</td><td><strong>0.038</strong></td></tr><tr><td>区域内聚集 · v4 试运行</td><td>07-15~07-31</td><td>72 / 6</td><td><strong>91.7%</strong></td><td>272</td><td>16</td><td><strong>0.059</strong></td></tr></tbody></table></div><p class="metric-note"><strong>口径说明:</strong>按规则版本分别报告召回率与每路每天误报数;不提供跨场景统一“准确率”。样本量和冻结窗口随导出保留。</p></article>
</section>
<section class="view" data-page="sites">
<div class="page-head"><div><h1>站点与 Area</h1><p>Bell 是 Tenant、Site、Area、配额与隐私准入策略的唯一真相源;Sense 只消费版本化投影。</p></div><div class="page-actions"><button class="btn">管理角色权限</button><button class="btn btn-primary" id="newSite">新建站点</button></div></div>
<div class="grid stats"><article class="card stat"><span>租户</span><strong>1</strong><span class="muted">青藤教育集团</span></article><article class="card stat"><span>站点</span><strong>2</strong><span class="muted">1 个试点中</span></article><article class="card stat"><span>视频配额</span><strong>16 / 128</strong><span class="muted">默认 16,不是硬上限</span></article><article class="card stat"><span>策略投影</span><strong>v42</strong><span class="muted">Sense 2 分钟前已同步</span></article></div>
<div class="grid site-grid"><article class="card"><div class="card-head"><h2>站点</h2><button class="btn btn-quiet">筛选</button></div><div class="site-list"><button class="site-item active"><span><strong>青藤寄宿学校</strong><small>site-ivy · S2 校园包</small></span><span class="badge success">启用</span></button><button class="site-item"><span><strong>城东试验点</strong><small>site-east · 未绑定场景包</small></span><span class="badge warning">准备中</span></button></div></article>
<article class="card"><div class="card-head"><div><h2>青藤寄宿学校 · Area</h2><span class="muted">层级、报表口径和设备准入共用同一逻辑 ID</span></div><button class="btn btn-primary" id="newArea">新建 Area</button></div><div class="policy-note"><strong>策略变更不会静默处置已有设备。</strong> 若改为“仅非成像”且 Area 内已有摄像头,必须显式迁移设备或取消变更。</div><div class="table-wrap"><table><thead><tr><th>Area</th><th>父级</th><th>capture_policy</th><th>设备</th><th>策略版本</th><th>投影状态</th><th>操作</th></tr></thead><tbody><tr><td><strong>北围墙</strong><br><span class="muted">area-north-wall</span></td><td>校园</td><td><span class="badge success">允许成像</span></td><td>4 视频</td><td>v42</td><td><span class="badge success">已同步</span></td><td><button class="btn" data-edit-area="north">编辑策略</button></td></tr><tr><td><strong>宿舍卧室</strong><br><span class="muted">area-dorm-room</span></td><td>宿舍楼</td><td><span class="badge warning">仅非成像</span></td><td>0 视频 · 1 雷达规划</td><td>v38</td><td><span class="badge success">已同步</span></td><td><button class="btn" data-edit-area="dorm">编辑策略</button></td></tr><tr><td><strong>实验楼入口</strong><br><span class="muted">area-lab-gate</span></td><td>实验楼</td><td><span class="badge success">允许成像</span></td><td>2 视频</td><td>v41</td><td><span class="badge warning">待 Sense 拉取</span></td><td><button class="btn" data-edit-area="lab">编辑策略</button></td></tr></tbody></table></div></article></div>
</section>
<section class="view" data-page="audit">
<div class="context-strip" id="auditContext" hidden><p><strong>来自 Sense 设备日志:</strong>租户 <span id="auditTenant">—</span> · 站点 <span id="auditSite">—</span> · 设备 <span id="auditDevice">—</span>。已锁定筛选上下文。</p><a class="btn" id="auditReturn" href="../sense/index.html?view=device-detail">返回设备详情 ↗</a></div>
<div class="page-head"><div><h1>审计日志</h1><p>查询、ack、规则发布和证据下载均可追溯;不记录密码与可复用连接串。</p></div><div class="page-actions"><button class="btn">导出审计记录</button></div></div>
<article class="card"><div class="card-head"><div class="search"><svg class="icon"><use href="#i-search"/></svg><input class="field" id="auditSearch" aria-label="搜索审计日志" placeholder="操作、角色或对象编号"></div><span class="badge info">只读</span></div><div class="table-wrap"><table><thead><tr><th>时间</th><th>角色</th><th>动作</th><th>对象</th><th>结果</th><th>来源</th></tr></thead><tbody id="auditRows"></tbody></table></div><div class="pager"><span id="auditPageSummary">第 1 / 28 页 · 每页 3 条</span><div class="pager-actions"><button class="btn" id="auditPrev">上一页</button><button class="btn" id="auditNext">下一页</button></div></div></article>
</section>
</div>
</main>
</div>
<nav class="mobile-nav" aria-label="移动端主导航"><button class="active" data-view="duty">值班</button><button data-view="events">事件</button><button data-view="rules">规则</button><button data-view="chains">升级</button><button data-view="reports">报表</button></nav>
<dialog id="ruleDialog" aria-labelledby="ruleTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="ruleTitle">配置告警规则</h2><span class="muted">规则草稿 · 尚未正式生效</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭对话框">×</button></div><div class="dialog-body"><div class="form-grid">
<div class="form-field"><label for="ruleScene">场景模板</label><select id="ruleScene" class="select"><option>非授权人员进入</option><option>区域内聚集</option><option>方向越线</option></select></div>
<div class="form-field"><label for="ruleScope">继承与覆盖</label><select id="ruleScope" class="select"><option>当前设备覆盖</option><option>继承站点模板</option><option>覆盖站点模板</option></select></div>
<div class="form-field"><label for="ruleCamera">摄像头</label><select id="ruleCamera" class="select"><option value="CAM-S2-001">北围墙 01</option><option value="CAM-S2-006">实验楼入口 02</option><option value="CAM-S2-011">宿舍东通道</option></select></div>
<div class="form-field"><label for="ruleZone">检测区域</label><select id="ruleZone" class="select"><option value="ZONE-007">北围墙危险区域 · v7</option><option value="ZONE-011">北围墙方向警戒线 · v3</option></select></div>
<div class="linked-zone"><span><strong>空间坐标由摄像头详情维护</strong><small>区域版本变化不会绕过规则试运行或自动正式发布。</small></span><a class="btn" id="editRuleZone" href="../sense/index.html?view=device-detail&amp;camera=CAM-S2-001&amp;zone=ZONE-007&amp;return_to=bell-rule">编辑检测区域</a></div>
<div class="form-field"><label for="ruleSchedule">生效时段</label><select id="ruleSchedule" class="select"><option>全天</option><option>夜间 22:00–06:00</option><option>自定义课表</option></select></div>
<div class="form-field"><label for="ruleDuration">持续时间</label><select id="ruleDuration" class="select"><option>超过 3 秒</option><option>立即</option><option>超过 10 秒</option><option>超过 20 秒</option></select></div>
<div class="form-field full"><label><input id="startDryRun" type="checkbox" checked aria-label="保存后开始试运行"> 保存后开始试运行,不触发正式预警</label></div>
<div class="inline-alert danger form-field full" id="ruleConflict" role="alert" hidden><strong>规则已由周管理员发布为 v8。</strong><br>当前草稿基于 v7,不能覆盖。请刷新到 v8 后重新合并,或另存为新草稿。</div>
<p class="workflow-note">产品流程:先确认摄像头和检测区域,再配置业务条件;试运行样本达到现场评审要求后,才允许正式发布。</p>
</div></div><div class="dialog-foot"><button type="button" class="btn" id="simulateRuleConflict">演示版本冲突</button><button class="btn" value="cancel">取消</button><button class="btn btn-primary" id="saveRuleDraft" value="default">保存并开始试运行</button></div></form></dialog>
<dialog id="falseDialog" aria-labelledby="falseTitle"><form method="dialog"><div class="dialog-head"><h2 id="falseTitle">标记为误报</h2><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭对话框">×</button></div><div class="dialog-body"><p>这只会追加事件 outcome,不会修改原始事件事实和证据。</p><div class="form-grid"><div class="form-field full"><label for="falseReason">误报原因</label><select id="falseReason" class="select" required><option value="">请选择</option><option>区域配置不准确</option><option>目标分类错误</option><option>时段策略不准确</option><option>其他</option></select></div><div class="form-field full"><label for="falseNote">补充说明(可选)</label><textarea id="falseNote" class="textarea" placeholder="便于算法团队归因,不填写真实个人信息"></textarea></div><div class="form-field full"><label><input type="checkbox" checked> 进入标注复核队列</label></div></div></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button class="btn btn-danger" id="confirmFalse" value="default">确认标记</button></div></form></dialog>
<dialog id="muteDialog" aria-labelledby="muteTitle"><form method="dialog"><div class="dialog-head"><h2 id="muteTitle">创建限时静默</h2><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭对话框">×</button></div><div class="dialog-body"><div class="form-grid"><div class="form-field"><label for="muteScope">影响范围</label><select id="muteScope" class="select"><option>北围墙 01 · 当前规则</option><option>北围墙区域</option></select></div><div class="form-field"><label for="muteTime">持续时间</label><select id="muteTime" class="select"><option>30 分钟</option><option>1 小时</option><option>2 小时</option><option>4 小时(上限)</option></select></div><div class="form-field full"><label for="muteCause">原因</label><textarea id="muteCause" class="textarea" required placeholder="例如:现场施工维护,已安排人工巡查"></textarea></div></div><p class="muted">到期自动恢复;S1 高危事件与设备离线运维告警不受此静默影响。</p></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button class="btn btn-primary" id="confirmMute" value="default">确认并记录审计</button></div></form></dialog>
<dialog id="areaDialog" aria-labelledby="areaTitle"><form method="dialog"><div class="dialog-head"><h2 id="areaTitle">编辑 Area 准入策略</h2><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭对话框">×</button></div><div class="dialog-body"><div class="form-grid"><div class="form-field"><label for="areaName">Area</label><input id="areaName" class="field" value="北围墙"></div><div class="form-field"><label for="areaParent">父级</label><select id="areaParent" class="select"><option>校园</option><option>教学区</option><option>宿舍楼</option></select></div><div class="form-field full"><label for="areaPolicy">capture_policy</label><select id="areaPolicy" class="select"><option value="video_allowed">允许成像设备</option><option value="non_imaging_only">仅允许非成像设备</option></select></div><div class="form-field full"><label for="areaReason">变更原因</label><textarea id="areaReason" class="textarea" placeholder="必填,进入全局审计"></textarea></div></div><p class="muted">保存生成新策略版本;Sense 投影同步前会显示旧版本,不允许绕过后端准入检查。</p></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-primary" id="saveAreaPolicy">保存策略版本</button></div></form></dialog>
<dialog id="policyConflictDialog" aria-labelledby="policyConflictTitle"><form method="dialog"><div class="dialog-head"><h2 id="policyConflictTitle">策略与已有设备冲突</h2><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭对话框">×</button></div><div class="dialog-body"><p>北围墙当前有 4 台成像设备。改为“仅非成像”前必须选择显式处置;系统不会自动停用、移动或保留违规设备。</p><label class="radio-card"><input type="radio" name="conflictAction" value="move"> <strong>先迁移 4 台设备到允许成像的 Area</strong><br><span class="muted">创建迁移任务,全部成功后再发布策略。</span></label><label class="radio-card"><input type="radio" name="conflictAction" value="cancel" checked> <strong>取消本次策略变更</strong><br><span class="muted">保持 v42 和现有设备不变。</span></label></div><div class="dialog-foot"><button class="btn" value="cancel">返回编辑</button><button class="btn btn-primary" value="default" id="confirmPolicyConflict">确认选择</button></div></form></dialog>
<dialog id="manageDialog" aria-labelledby="manageTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="manageTitle">管理</h2><span class="muted">移动端保留 5 个主入口,低频管理能力从这里进入。</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭管理菜单">×</button></div><div class="dialog-body"><div class="relation-list"><button type="button" class="relation-item" data-manage-view="sites"><strong>站点与 Area</strong><span>配额、capture_policy、RBAC →</span></button><button type="button" class="relation-item" data-manage-view="audit"><strong>审计日志</strong><span>只读、筛选、分页 →</span></button></div></div></form></dialog>
<dialog id="eventDialog" aria-labelledby="eventDialogTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="eventDialogTitle">事件详情</h2><span class="muted" id="eventDialogSubtitle">不可变事实</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭事件详情">×</button></div><div class="dialog-body"><div class="fact-grid" style="margin:0 0 14px"><div class="fact"><span>事件编号</span><strong class="mono" id="eventDetailId">—</strong></div><div class="fact"><span>点位</span><strong id="eventDetailSite">—</strong></div><div class="fact"><span>规则</span><strong id="eventDetailRule">—</strong></div><div class="fact"><span>证据</span><strong id="eventDetailEvidence">—</strong></div></div><div class="relation-panel" style="margin:0"><h3>触发的预警</h3><div class="relation-list" id="eventAlerts"></div></div></div><div class="dialog-foot"><button class="btn" value="cancel">关闭</button></div></form></dialog>
<dialog id="handoverDialog" aria-labelledby="handoverTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="handoverTitle">值班交接</h2><span class="muted">白班 · 林值班员 → 晚班</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭交接班">×</button></div><div class="dialog-body"><div class="form-grid"><div class="form-field"><label for="handoverTo">接班人</label><select class="select" id="handoverTo"><option value="">请选择并确认本人已在场</option><option>王值班员 · 晚班</option><option>赵值班员 · 晚班</option></select></div><div class="form-field"><label for="handoverAt">交接时间</label><input class="field" id="handoverAt" value="2026-08-04 20:00" readonly></div><div class="form-field full"><label>待交接 Alert</label><div class="relation-list"><div class="relation-item"><strong>ALT-1842 · 未 ack</strong><span>下一次升级 01:42</span></div><div class="relation-item"><strong>ALT-1841 · 处置中</strong><span>王值班员现场核查</span></div><div class="relation-item"><strong>ALT-1839 · 升级中</strong><span>短信重试 2/3</span></div></div></div><div class="form-field full"><label for="handoverNote">交接备注</label><textarea class="textarea" id="handoverNote" placeholder="记录现场状态、已采取动作和待跟进事项"></textarea></div></div><p class="metric-note">交接期间升级链继续计时,不暂停、不重置;只转移这些进行中 Alert 的处置责任,不修改未来排班。未来班次变化请在“值班与排班”创建替班版本。</p></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-primary" id="confirmHandover">接班人确认并留痕</button></div></form></dialog>
<dialog id="rollbackDialog" aria-labelledby="rollbackTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="rollbackTitle">版本历史与回滚</h2><span class="muted">RULE-007 · 历史版本不可变</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭版本历史">×</button></div><div class="dialog-body"><div class="version-diff"><article><strong>当前 v7 · 2026-08-01 正式发布</strong><span class="muted">持续 3 秒 · 全天 · 4 个点位 · 区域 v7</span></article><article><strong>目标 v6 · 2026-07-18 历史版本</strong><span class="muted">持续 5 秒 · 夜间 22:00–06:00 · 3 个点位 · 区域 v6</span></article><article><strong>影响评估</strong><span class="muted">将减少 1 个点位并收窄时段;预计每日预警 -18%。确认后创建并发布 v8,使其配置等价于 v6,v6/v7 均保持不变。</span></article></div></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-danger" id="confirmRollback">创建并发布回滚版本 v8</button></div></form></dialog>
<dialog id="escalationDialog" aria-labelledby="escalationTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="escalationTitle">编辑升级步骤</h2><span class="muted">默认链 v5 草稿 · 不直接保存手机号</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭升级步骤编辑">×</button></div><div class="dialog-body"><div class="form-grid"><div class="form-field"><label for="escalationTargetType">目标类型</label><select class="select" id="escalationTargetType"><option value="schedule">值班排班</option><option value="team">固定值班组</option><option value="person">指定人员</option></select></div><div class="form-field"><label for="escalationTargetRef">目标</label><select class="select" id="escalationTargetRef"></select></div><div class="form-field"><label for="escalationChannel">首选通道</label><select class="select" id="escalationChannel"><option>短信 + 语音兜底</option><option>Web + 本地声光</option><option>语音 + 短信兜底</option></select></div><div class="form-field"><label for="escalationTimeout">等待 ack</label><select class="select" id="escalationTimeout"><option>2 分钟</option><option>3 分钟</option><option>5 分钟</option></select></div><div class="form-field full"><label>当前解析预览</label><div class="resolve-item" id="escalationPreview" aria-live="polite"></div></div></div><p class="workflow-note">保存的是类型化目标引用与通道偏好。每次投递创建时按站点时区解析生效排班,再固化收件人、通道和排班版本快照。</p></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-primary" id="saveEscalation">保存策略草稿</button></div></form></dialog>
<dialog id="scheduleDialog" aria-labelledby="scheduleTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="scheduleTitle">发布值班排班</h2><span class="muted">历史版本不可变</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭排班发布">×</button></div><div class="dialog-body"><div class="form-grid"><div class="form-field"><label for="scheduleName">排班计划</label><input class="field" id="scheduleName" value="校园安保值班表" readonly></div><div class="form-field"><label for="scheduleNextVersion">新版本</label><input class="field" id="scheduleNextVersion" value="v13" readonly></div><div class="form-field"><label for="scheduleTimezone">站点时区</label><select class="select" id="scheduleTimezone"><option>Asia/Shanghai</option></select></div><div class="form-field"><label for="scheduleEffective">生效时间</label><input class="field" id="scheduleEffective" type="datetime-local" value="2026-08-05T20:00"></div><div class="form-field full"><label for="scheduleReason">发布说明</label><textarea class="textarea" id="scheduleReason" placeholder="必填,例如:王值班员临时替班,覆盖周三晚班"></textarea></div></div><p class="metric-note">发布前重新检查空档、重叠、联系人停用和通道验证。新版本只影响生效时间后的投递,不回写 v12 或既有投递快照。</p></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-primary" id="confirmSchedulePublish">确认发布 v13</button></div></form></dialog>
<dialog id="substituteDialog" aria-labelledby="substituteTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="substituteTitle">创建临时替班</h2><span class="muted">保存到 v13 草稿,发布后生效</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭临时替班">×</button></div><div class="dialog-body"><div class="form-grid"><div class="form-field"><label for="substituteShift">被替换班次</label><select class="select" id="substituteShift"><option>周三晚班 · 周值班员 · 20:00–08:00</option><option>周四白班 · 林值班员 · 08:00–20:00</option></select></div><div class="form-field"><label for="substitutePerson">替班人</label><select class="select" id="substitutePerson"><option value="">请选择可用联系人</option><option>王值班员</option><option>赵值班员</option></select></div><div class="form-field full"><label for="substituteReason">替班原因</label><textarea class="textarea" id="substituteReason" placeholder="必填,写入排班版本审计"></textarea></div></div><p class="workflow-note">临时替班修改未来排班。若只是把正在处置的 Alert 交给下一班,请使用值班台“交接班”,不要改排班。</p></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-primary" id="saveSubstitute">保存到 v13 草稿</button></div></form></dialog>
<dialog id="contactDialog" aria-labelledby="contactTitle"><form method="dialog"><div class="dialog-head"><div><h2 id="contactTitle">新建联系人</h2><span class="muted">联系人不包含班次和轮换字段</span></div><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭联系人编辑">×</button></div><div class="dialog-body"><div class="form-grid"><div class="form-field"><label for="contactName">姓名</label><input class="field" id="contactName" placeholder="联系人显示名"></div><div class="form-field"><label for="contactRole">角色</label><select class="select" id="contactRole"><option>值班员</option><option>站点负责人</option><option>租户管理员</option></select></div><div class="form-field"><label for="contactTeam">值班组</label><select class="select" id="contactTeam"><option>校园安保组</option><option>校级负责人组</option></select></div><div class="form-field"><label for="contactPhone">新通知号码</label><input class="field" id="contactPhone" type="tel" placeholder="输入后发送验证码;不回显旧值"></div><div class="form-field full"><label>启用通道</label><label class="radio-card"><input type="checkbox" id="contactSms" checked> 短信(需验证)</label><label class="radio-card"><input type="checkbox" id="contactVoice"> 语音(需验证)</label><label class="radio-card"><input type="checkbox" id="contactWeb" checked> Web 站内通知</label></div></div><p class="metric-note">联系人保存身份、角色、值班组与已验证通道。加入哪个班次、何时轮换由“值班与排班”模块引用联系人 ID 完成。</p></div><div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-primary" id="saveContact">保存并发起通道验证</button></div></form></dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const toast = $('#toast');
let toastTimer;
function notify(message) {
toast.textContent = message;
toast.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.classList.remove('show'), 3800);
}
const alerts = [
{id:'ALT-1842',severity:'S1 高危',badge:'danger',title:'危险区域闯入',site:'北围墙 01',time:'23:14:08',age:'1 分钟',rule:'非授权人员进入',delivery:'短信等待回执',events:[{id:'EVT-01J8…7A2',reason:'同点位 30 秒窗口聚合'},{id:'EVT-01J8…7B4',reason:'同规则连续命中聚合'}]},
{id:'ALT-1841',severity:'S1 高危',badge:'danger',title:'方向越线',site:'宿舍东通道',time:'23:12:46',age:'3 分钟',rule:'夜间越线 A → B',delivery:'声光已看到',events:[{id:'EVT-01J8…71M',reason:'单事件触发'}]},
{id:'ALT-1839',severity:'S2 重要',badge:'warning',title:'区域聚集',site:'操场西北角',time:'23:08:21',age:'7 分钟',rule:'人数 ≥ 6 持续 20 秒',delivery:'Web 已看到',events:[{id:'EVT-01J8…52C',reason:'同站点 60 秒窗口聚合'}]},
{id:'ALT-1836',severity:'S2 重要',badge:'warning',title:'危险区域滞留',site:'配电房门口',time:'22:57:03',age:'18 分钟',rule:'区域内停留 ≥ 10 秒',delivery:'短信最终失败',events:[{id:'EVT-01J8…44K',reason:'单事件触发'}]}
];
const eventRecords = [
{id:'EVT-01J8…7A2',time:'23:14:08',site:'北围墙 01',rule:'非授权人员进入',fact:'已确认',factBadge:'danger',result:'处置中',evidence:'可用',evidenceBadge:'success',alerts:[{id:'ALT-1842',state:'等待 ack · 聚合 2 个事件'}]},
{id:'EVT-01J8…7B4',time:'23:14:21',site:'北围墙 01',rule:'非授权人员进入',fact:'已确认',factBadge:'danger',result:'已聚合',evidence:'可用',evidenceBadge:'success',alerts:[{id:'ALT-1842',state:'等待 ack · 同点位窗口聚合'}]},
{id:'EVT-01J8…69P',time:'22:48:31',site:'宿舍东通道',rule:'区域内聚集',fact:'已记录',factBadge:'info',result:'试运行样本',evidence:'可用',evidenceBadge:'success',alerts:[]},
{id:'EVT-01J8…52C',time:'22:19:04',site:'实验楼入口',rule:'危险区域闯入',fact:'已确认',factBadge:'danger',result:'误报',evidence:'重试中',evidenceBadge:'warning',alerts:[{id:'ALT-1839',state:'处置完成 · 误报'}]},
{id:'EVT-01J8…71M',time:'21:58:20',site:'宿舍东通道',rule:'夜间越线 A → B',fact:'已确认',factBadge:'danger',result:'处置中',evidence:'可用',evidenceBadge:'success',alerts:[{id:'ALT-1841',state:'已 ack · 现场核查'}]},
{id:'EVT-01J8…44K',time:'21:31:02',site:'配电房门口',rule:'区域内停留 ≥ 10 秒',fact:'已确认',factBadge:'warning',result:'升级中',evidence:'仅抓拍',evidenceBadge:'warning',alerts:[{id:'ALT-1836',state:'短信最终失败 · 语音待发送'}]}
];
const auditRecords = [
{time:'23:14:18',role:'站点值班员',action:'ACK_ALERT',object:'ALT-1842',result:'成功',badge:'success',source:'值班室 Web'},
{time:'22:52:06',role:'站点管理员',action:'PUBLISH_RULE',object:'RULE-007',result:'成功',badge:'success',source:'管理端'},
{time:'22:20:31',role:'只读用户',action:'VIEW_EVIDENCE',object:'EVT-01J8…52C',result:'拒绝',badge:'danger',source:'响应式 H5'},
{time:'21:56:04',role:'站点值班员',action:'HANDOVER_SHIFT',object:'SHIFT-20260804-DAY',result:'成功',badge:'success',source:'值班室 Web'},
{time:'21:41:12',role:'租户管理员',action:'ROLLBACK_RULE',object:'RULE-007:v8',result:'成功',badge:'success',source:'管理端'},
{time:'20:05:55',role:'站点管理员',action:'UPDATE_AREA_POLICY',object:'area-lab-gate:v41',result:'成功',badge:'success',source:'管理端'}
];
function makeTextCell(row, value, className = '') {
const cell = document.createElement('td');
cell.textContent = value;
if (className) cell.className = className;
row.append(cell);
return cell;
}
function makeBadgeCell(row, value, badge) {
const cell = document.createElement('td');
const span = document.createElement('span');
span.className = `badge ${badge}`;
span.textContent = value;
cell.append(span);
row.append(cell);
}
let selectedAlertIndex = 0;
let ackOwner = null;
const alertList = $('#alertList');
function renderAlerts(selected = selectedAlertIndex) {
alertList.innerHTML = alerts.map((alert, index) => `<button class="alert-card ${index === selected ? 'selected' : ''}" data-index="${index}"><div class="alert-card-top"><span class="badge ${alert.badge}"><i class="severity-dot"></i>${alert.severity}</span><span class="muted">${alert.age}</span></div><h3>${alert.title}</h3><p>${alert.site} · ${alert.time}</p><div class="alert-card-foot"><span>${alert.delivery}</span><span class="mono">${alert.id}</span></div></button>`).join('');
$$('.alert-card', alertList).forEach(button => button.addEventListener('click', () => selectAlert(Number(button.dataset.index))));
}
function renderRelatedEvents(alert) {
const container = $('#relatedEvents');
container.replaceChildren();
alert.events.forEach(event => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'relation-item';
const strong = document.createElement('strong');
strong.className = 'mono';
strong.textContent = event.id;
const reason = document.createElement('span');
reason.textContent = `${event.reason} →`;
button.append(strong, reason);
button.addEventListener('click', () => showEventDetail(event.id));
container.append(button);
});
$('#relatedEventCount').textContent = String(alert.events.length);
}
function resetAck() {
ackOwner = null;
$('#ackState').className = 'badge warning';
$('#ackState').textContent = '等待 ack';
$('#ackButton').disabled = false;
$('#ackButton').innerHTML = '<svg class="icon"><use href="#i-check"/></svg>确认接手';
$('#ackHelp').textContent = '一次点击确认;并发确认时会显示实际接手人,不覆盖对方结果。';
$('#ackConflict').hidden = true;
}
function selectAlert(index) {
selectedAlertIndex = index;
renderAlerts(index);
const alert = alerts[index];
$('#selectedSeverity').className = `badge ${alert.badge}`;
$('#selectedSeverity').textContent = alert.severity;
$('#selectedTitle').textContent = alert.title;
$('#selectedMeta').textContent = `${alert.site} · ${alert.time} · ${alert.id}`;
$('#factRule').textContent = alert.rule;
renderRelatedEvents(alert);
resetAck();
}
function openAlert(alertId) {
const index = alerts.findIndex(alert => alert.id === alertId);
if (index < 0) {
notify(`预警 ${alertId} 不在当前队列,请从历史预警查询。`);
return;
}
if ($('#eventDialog').open) $('#eventDialog').close();
switchView('duty');
selectAlert(index);
notify(`已从事件打开关联预警 ${alertId}。`);
}
function showEventDetail(eventId) {
const event = eventRecords.find(item => item.id === eventId) || eventRecords[0];
$('#eventDialogTitle').textContent = `${event.id} · 事件详情`;
$('#eventDialogSubtitle').textContent = `${event.time} · 事实不可由处置改写`;
$('#eventDetailId').textContent = event.id;
$('#eventDetailSite').textContent = event.site;
$('#eventDetailRule').textContent = event.rule;
$('#eventDetailEvidence').textContent = event.evidence;
const container = $('#eventAlerts');
container.replaceChildren();
if (!event.alerts.length) {
const message = document.createElement('div');
message.className = 'inline-alert';
message.textContent = '未创建 Alert:当前为试运行样本或已被规则抑制;不伪装成投递成功。';
container.append(message);
} else {
event.alerts.forEach(alert => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'relation-item';
const strong = document.createElement('strong');
strong.className = 'mono';
strong.textContent = alert.id;
const state = document.createElement('span');
state.textContent = `${alert.state} →`;
button.append(strong, state);
button.addEventListener('click', () => openAlert(alert.id));
container.append(button);
});
}
$('#eventDialog').showModal();
}
let eventPage = 1;
const eventTotalPages = 16;
function renderEventPage() {
const start = ((eventPage - 1) * 3) % eventRecords.length;
const pageItems = [0, 1, 2].map(offset => eventRecords[(start + offset) % eventRecords.length]);
const body = $('#eventRows');
body.replaceChildren();
pageItems.forEach(event => {
const row = document.createElement('tr');
const idCell = document.createElement('td');
const eventButton = document.createElement('button');
eventButton.type = 'button';
eventButton.className = 'text-button mono';
eventButton.textContent = event.id;
eventButton.addEventListener('click', () => showEventDetail(event.id));
idCell.append(eventButton);
row.append(idCell);
makeTextCell(row, event.time);
makeTextCell(row, event.site);
makeTextCell(row, event.rule);
makeBadgeCell(row, event.fact, event.factBadge);
const alertCell = document.createElement('td');
if (event.alerts.length) {
event.alerts.forEach(alert => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'text-button mono';
button.textContent = alert.id;
button.addEventListener('click', () => openAlert(alert.id));
alertCell.append(button);
});
} else {
alertCell.textContent = '未触发';
}
row.append(alertCell);
makeTextCell(row, event.result);
makeBadgeCell(row, event.evidence, event.evidenceBadge);
body.append(row);
});
$('#eventPageSummary').textContent = `第 ${eventPage} / ${eventTotalPages} 页 · 每页 3 条 · 共 47 条`;
$('#eventPrev').disabled = eventPage === 1;
$('#eventNext').disabled = eventPage === eventTotalPages;
}
let auditPage = 1;
const auditTotalPages = 28;
function renderAuditPage() {
const start = ((auditPage - 1) * 3) % auditRecords.length;
const pageItems = [0, 1, 2].map(offset => auditRecords[(start + offset) % auditRecords.length]);
const body = $('#auditRows');
body.replaceChildren();
pageItems.forEach(item => {
const row = document.createElement('tr');
makeTextCell(row, item.time);
makeTextCell(row, item.role);
makeTextCell(row, item.action);
makeTextCell(row, item.object, 'mono');
makeBadgeCell(row, item.result, item.badge);
makeTextCell(row, item.source);
body.append(row);
});
$('#auditPageSummary').textContent = `第 ${auditPage} / ${auditTotalPages} 页 · 每页 3 条 · 共 84 条`;
$('#auditPrev').disabled = auditPage === 1;
$('#auditNext').disabled = auditPage === auditTotalPages;
}
const validViews = new Set($$('.view').map(view => view.dataset.page));
function switchView(requested) {
const name = validViews.has(requested) ? requested : 'duty';
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.page === name));
$$('[data-view]').forEach(button => {
const active = button.dataset.view === name;
button.toggleAttribute('aria-current', active);
button.classList.toggle('active', active);
});
$('#stateSelect').value = 'normal';
renderState('normal');
$('#main-content').focus({preventScroll:true});
}
$$('[data-view]').forEach(button => button.addEventListener('click', () => switchView(button.dataset.view)));
$$('.queue-tabs button').forEach(button => button.addEventListener('click', () => {
$$('.queue-tabs button').forEach(item => { const active = item === button; item.classList.toggle('active', active); item.setAttribute('aria-pressed', String(active)); });
notify(`队列已切换到“${button.textContent}”`);
}));
$$('.evidence-tabs button').forEach(button => button.addEventListener('click', () => {
$$('.evidence-tabs button').forEach(item => { const active = item === button; item.classList.toggle('active', active); item.setAttribute('aria-pressed', String(active)); });
notify(`正在演示“${button.textContent}”标签结构`);
}));
function renderState(state) {
const layer = $('#stateLayer');
const views = $$('.view');
layer.classList.toggle('show', state !== 'normal');
views.forEach(view => { view.style.display = state === 'normal' ? '' : 'none'; });
if (state === 'normal') return;
const states = {
loading:'<div><div class="skeleton" aria-label="正在加载"><i></i><i></i><i></i><i></i></div><h2>正在读取最新预警状态</h2><p>超过 300ms 显示进度;ack 和升级事实始终从服务端重新读取。</p></div>',
weak:'<div><svg class="icon" style="color:var(--warning)"><use href="#i-alert"/></svg><h2>结构化事件已到达,视频证据暂不可用</h2><p>弱网下先显示时间、点位、规则和升级链。视频片段可单独重试,不阻塞确认接手。</p><button class="btn" data-state-recover="weak">后台重试证据</button></div>',
empty:'<div><svg class="icon"><use href="#i-check"/></svg><h2>当前没有待处置预警</h2><p>系统在线,新的 Alert 会实时进入队列。历史事件仍可从事件中心查询。</p><button class="btn" data-empty-events>查看事件中心</button></div>',
denied:'<div><svg class="icon" style="color:var(--danger)"><use href="#i-lock"/></svg><h2>没有查看此站点证据的权限</h2><p>当前账号只能查看被授权站点。系统不会泄露事件是否存在、流地址或设备信息。</p><button class="btn" data-state-recover="denied">返回可访问范围</button></div>',
expired:'<div><svg class="icon" style="color:var(--danger)"><use href="#i-lock"/></svg><h2>会话已过期</h2><p>为保护预警和证据,页面已停止提交 ack 与处置。重新登录后从服务端读取最新责任人和升级状态,不重放旧操作。</p><button class="btn" data-state-recover="expired">重新登录并刷新状态</button></div>'
};
layer.innerHTML = states[state];
$('[data-state-recover]', layer)?.addEventListener('click', () => { $('#stateSelect').value = 'normal'; renderState('normal'); notify(state === 'weak' ? '证据重试任务已加入后台队列。' : '已刷新当前权限与服务端状态。'); });
$('[data-empty-events]', layer)?.addEventListener('click', () => switchView('events'));
}
$('#stateSelect').addEventListener('change', event => renderState(event.target.value));
$('#themeToggle').addEventListener('click', () => { const root = document.documentElement; root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark'; notify(`已切换为${root.dataset.theme === 'dark' ? '深色' : '浅色'}主题`); });
$('#eventPrev').addEventListener('click', () => { if (eventPage > 1) { eventPage -= 1; renderEventPage(); } });
$('#eventNext').addEventListener('click', () => { if (eventPage < eventTotalPages) { eventPage += 1; renderEventPage(); } });
$('#auditPrev').addEventListener('click', () => { if (auditPage > 1) { auditPage -= 1; renderAuditPage(); } });
$('#auditNext').addEventListener('click', () => { if (auditPage < auditTotalPages) { auditPage += 1; renderAuditPage(); } });
$('#ackButton').addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
button.textContent = '确认中…';
await new Promise(resolve => setTimeout(resolve, 650));
if (ackOwner && ackOwner !== 'current') {
button.textContent = '当前预警已由他人接手';
$('#ackConflict').hidden = false;
$('#ackConflict').textContent = '确认未生效:王值班员已于 23:15:01 抢先 ack。已刷新为服务端结果,没有覆盖对方。';
notify('并发确认未覆盖:请与当前处置人协作。');
return;
}
ackOwner = 'current';
button.innerHTML = '<svg class="icon"><use href="#i-check"/></svg>已由当前值班员接手';
$('#ackState').className = 'badge success';
$('#ackState').textContent = '已 ack';
$('#ackHelp').textContent = '23:15:02 · 当前值班员 · 服务端确认成功;通道回执仍单独保留。';
notify('确认成功,升级计时已停止并记录审计。');
});
$('#simulateAckConflict').addEventListener('click', () => {
ackOwner = 'other';
$('#ackButton').disabled = true;
$('#ackButton').textContent = '已由王值班员接手';
$('#ackState').className = 'badge info';
$('#ackState').textContent = '已 ack · 他人';
$('#ackHelp').textContent = '23:15:01 · 王值班员 · 服务端首个成功结果';
$('#ackConflict').hidden = false;
$('#ackConflict').textContent = '你的 ack 未生效:王值班员抢先成功。页面已刷新实际处置人,没有静默覆盖或显示双成功。';
});
$('#completeAlert').addEventListener('click', () => {
if (!$('#outcome').value) { notify('请先选择处置结果。'); $('#outcome').focus(); return; }
if (ackOwner !== 'current') { notify(ackOwner === 'other' ? '当前处置人为王值班员,你不能覆盖其处置结果。' : '请先确认接手,再完成处置。'); $('#ackButton').focus(); return; }
notify('处置结果已追加,原始事件事实未被修改。');
});
$('#falsePositive').addEventListener('click', () => $('#falseDialog').showModal());
$('#confirmFalse').addEventListener('click', event => { if (!$('#falseReason').value) { event.preventDefault(); notify('请选择误报原因。'); $('#falseReason').focus(); return; } notify('已标记误报并进入复核队列,原始事件保持不变。'); });
$('#muteButton').addEventListener('click', () => $('#muteDialog').showModal());
$('#confirmMute').addEventListener('click', event => { if (!$('#muteCause').value.trim()) { event.preventDefault(); notify('请填写静默原因。'); $('#muteCause').focus(); return; } notify('已创建限时静默,到期将自动恢复并通知值班组。'); });
$('#handoverButton').addEventListener('click', () => $('#handoverDialog').showModal());
$('#confirmHandover').addEventListener('click', () => { if (!$('#handoverTo').value) { notify('请选择接班人并由本人确认。'); $('#handoverTo').focus(); return; } $('#handoverDialog').close(); notify('交接已由接班人确认并写入审计;3 条 Alert 的升级链继续运行。'); });
function switchChainSection(name) {
const target = ['strategy', 'roster', 'contacts'].includes(name) ? name : 'strategy';
$$('[data-chain-panel]').forEach(panel => panel.classList.toggle('active', panel.dataset.chainPanel === target));
$$('[data-chain-section]').forEach(button => {
const active = button.dataset.chainSection === target;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
});
}
$$('[data-chain-section]').forEach(button => button.addEventListener('click', () => switchChainSection(button.dataset.chainSection)));
const escalationTargets = {
schedule: [
{value:'SCH-CAMPUS-SECURITY',label:'校园安保值班表 · v12',preview:'王值班员 · 晚班 · 短信/语音已验证'},
{value:'SCH-SCHOOL-LEAD',label:'校级负责人轮值表 · v6',preview:'陈负责人 · 当前轮值 · 语音已验证'}
],
team: [
{value:'DUTY-ROOM',label:'值班室固定组',preview:'4 人在线 · Web + 本地声光'},
{value:'SCHOOL-LEADS',label:'校级负责人组',preview:'3 名成员 · 顺序通知'}
],
person: [
{value:'USR-SCHOOL-LEAD',label:'校级负责人',preview:'指定人员 · 短信已验证 · 语音待验证'},
{value:'USR-DUTY-WANG',label:'王值班员',preview:'指定人员 · 短信/语音/Web 已验证'}
]
};
function renderEscalationTargets() {
const type = $('#escalationTargetType').value;
const select = $('#escalationTargetRef');
select.replaceChildren();
escalationTargets[type].forEach(target => {
const option = document.createElement('option');
option.value = target.value;
option.textContent = target.label;
select.append(option);
});
renderEscalationPreview();
}
function renderEscalationPreview() {
const type = $('#escalationTargetType').value;
const target = escalationTargets[type].find(item => item.value === $('#escalationTargetRef').value) || escalationTargets[type][0];
const typeLabel = type === 'schedule' ? '值班排班' : type === 'team' ? '固定值班组' : '指定人员';
$('#escalationPreview').textContent = `${typeLabel} · ${target.label} → ${target.preview}。实际投递时固化解析快照。`;
}
$('#editEscalation').addEventListener('click', () => { renderEscalationTargets(); $('#escalationDialog').showModal(); });
$('#escalationTargetType').addEventListener('change', renderEscalationTargets);
$('#escalationTargetRef').addEventListener('change', renderEscalationPreview);
$('#saveEscalation').addEventListener('click', () => { $('#escalationDialog').close(); notify('升级策略草稿已保存为类型化目标引用;未复制联系人号码。'); });
let rosterHasConflict = false;
$('#simulateRosterConflict').addEventListener('click', event => {
rosterHasConflict = !rosterHasConflict;
$('#rosterConflict').hidden = !rosterHasConflict;
$('#rosterConflict').textContent = rosterHasConflict ? '发布已阻止:周三 20:00–21:00 存在排班重叠,同时安排了周值班员和王值班员;周四 08:00–09:00 存在空档。请调整后重新检查。' : '';
event.currentTarget.textContent = rosterHasConflict ? '恢复无冲突排班' : '演示冲突检查';
$('#openSchedulePublish').disabled = rosterHasConflict;
notify(rosterHasConflict ? '发现 1 个重叠和 1 个空档,排班不能发布。' : '冲突已修复,未来 14 天覆盖完整。');
});
$('#openSchedulePublish').addEventListener('click', () => { if (rosterHasConflict) { notify('请先修复排班空档和重叠。'); $('#rosterConflict').focus(); return; } $('#scheduleDialog').showModal(); });
$('#confirmSchedulePublish').addEventListener('click', () => {
if (!$('#scheduleReason').value.trim()) { notify('请填写排班发布说明。'); $('#scheduleReason').focus(); return; }
$('#scheduleDialog').close();
$('#scheduleVersion').textContent = 'v13';
notify('排班 v13 已发布,将在设定时间生效;v12 与既有投递快照保持不变。');
});
$('#openSubstitute').addEventListener('click', () => $('#substituteDialog').showModal());
$('#saveSubstitute').addEventListener('click', () => {
if (!$('#substitutePerson').value) { notify('请选择替班人。'); $('#substitutePerson').focus(); return; }
if (!$('#substituteReason').value.trim()) { notify('请填写替班原因。'); $('#substituteReason').focus(); return; }
$('#substituteDialog').close();
$('#wednesdayNightShift').textContent = `${$('#substitutePerson').value}(替班草稿)`;
$('#scheduleVersion').textContent = 'v13 草稿';
notify('临时替班已保存到 v13 草稿,发布前不会影响实际通知。');
});
let editingContact = false;
function openContactEditor(key = '') {
editingContact = Boolean(key);
const names = {wang:'王值班员',lin:'林值班员',lead:'校级负责人'};
$('#contactTitle').textContent = editingContact ? `编辑联系人 · ${names[key]}` : '新建联系人';
$('#contactName').value = editingContact ? names[key] : '';
$('#contactPhone').value = '';
$('#contactPhone').placeholder = editingContact ? '留空则保留已验证号码;旧值不回显' : '输入后发送验证码;不回显旧值';
$('#contactDialog').showModal();
}
$('#openContact').addEventListener('click', () => openContactEditor());
$$('[data-edit-contact]').forEach(button => button.addEventListener('click', () => openContactEditor(button.dataset.editContact)));
$('#saveContact').addEventListener('click', () => {
if (!$('#contactName').value.trim()) { notify('请填写联系人姓名。'); $('#contactName').focus(); return; }
const needsPhone = $('#contactSms').checked || $('#contactVoice').checked;
if (!editingContact && needsPhone && !$('#contactPhone').value.trim()) { notify('短信或语音通道需要填写新号码并完成验证。'); $('#contactPhone').focus(); return; }
$('#contactDialog').close();
if (!editingContact) $('#contactCount').textContent = '7';
notify(editingContact ? '联系人主数据已更新;既有投递快照未改变。' : '联系人已保存,短信/语音通道进入验证流程;尚未加入任何班次。');
});
$('#mobileManage').addEventListener('click', () => $('#manageDialog').showModal());
$$('[data-manage-view]').forEach(button => button.addEventListener('click', () => { $('#manageDialog').close(); switchView(button.dataset.manageView); notify(`已进入${button.textContent.trim()}。`); }));
$$('[data-edit-area]').forEach(button => button.addEventListener('click', () => { const isDorm = button.dataset.editArea === 'dorm'; $('#areaName').value = isDorm ? '宿舍卧室' : button.dataset.editArea === 'lab' ? '实验楼入口' : '北围墙'; $('#areaPolicy').value = isDorm ? 'non_imaging_only' : 'video_allowed'; $('#areaDialog').showModal(); }));
$('#newArea').addEventListener('click', () => { $('#areaName').value = ''; $('#areaPolicy').value = 'video_allowed'; $('#areaDialog').showModal(); });
$('#newSite').addEventListener('click', () => notify('新建站点需先填写时区、场景包、配额和管理员,本原型只枚举入口。'));
$('#saveAreaPolicy').addEventListener('click', () => { if (!$('#areaReason').value.trim()) { notify('请填写变更原因。'); $('#areaReason').focus(); return; } if ($('#areaName').value === '北围墙' && $('#areaPolicy').value === 'non_imaging_only') { $('#areaDialog').close(); $('#policyConflictDialog').showModal(); return; } $('#areaDialog').close(); notify('Area 策略新版本已保存,等待 Sense 拉取投影。'); });
$('#confirmPolicyConflict').addEventListener('click', () => { const action = $('input[name="conflictAction"]:checked').value; notify(action === 'move' ? '已创建 4 台设备迁移任务;完成前不会发布策略。' : '已取消策略变更,v42 和已有设备保持不变。'); });
const ruleDialog = $('#ruleDialog');
function openRuleEditor() { ruleDialog.showModal(); }
$('#openRuleDialog').addEventListener('click', openRuleEditor);
$('#continueRuleDraft').addEventListener('click', openRuleEditor);
$$('[data-edit-rule]').forEach(button => button.addEventListener('click', openRuleEditor));
function syncSenseZoneLink() { $('#editRuleZone').href = `../sense/index.html?view=device-detail&camera=${encodeURIComponent($('#ruleCamera').value)}&zone=${encodeURIComponent($('#ruleZone').value)}&return_to=bell-rule`; }
$('#ruleCamera').addEventListener('change', () => { syncSenseZoneLink(); notify('已切换摄像头,请确认检测区域版本与画面仍匹配。'); });
$('#ruleZone').addEventListener('change', syncSenseZoneLink);
$('#simulateRuleConflict').addEventListener('click', () => { $('#ruleConflict').hidden = false; $('#saveRuleDraft').disabled = true; notify('已读取服务端 v8;当前 v7 草稿被阻止覆盖。'); });
$('#saveRuleDraft').addEventListener('click', event => { if (!$('#ruleConflict').hidden) { event.preventDefault(); notify('版本冲突未解决,不能覆盖 v8。'); return; } notify($('#startDryRun').checked ? '规则草稿已保存并进入试运行,不会触发正式预警。' : '规则草稿已保存,尚未开始试运行。'); });
$('#publishTrialRule').addEventListener('click', () => { if (confirm('正式发布后将影响 3 个点位并启动预警链。确认发布当前试运行版本?')) { $('#trialBadge').className = 'badge success'; $('#trialBadge').textContent = '正式生效'; notify('规则版本已正式发布,变更已记录审计并可回滚。'); } });
$('#openRollback').addEventListener('click', () => $('#rollbackDialog').showModal());
$('#confirmRollback').addEventListener('click', () => { $('#rollbackDialog').close(); $('#publishedRuleBadge').textContent = '正式生效 · v8(回滚)'; notify('已创建并发布 v8,其配置等价于 v6;v6、v7 历史保持不变。'); });
let remain = 102;
setInterval(() => { if (ackOwner || remain <= 0) return; remain -= 1; const minutes = String(Math.floor(remain / 60)).padStart(2, '0'); const seconds = String(remain % 60).padStart(2, '0'); $('#countdown').textContent = `${minutes}:${seconds}`; }, 1000);
renderAlerts();
renderRelatedEvents(alerts[0]);
renderEventPage();
renderAuditPage();
const routeParams = new URLSearchParams(location.search);
if (routeParams.get('view')) switchView(routeParams.get('view'));
if (routeParams.get('view') === 'chains' && routeParams.get('section')) switchChainSection(routeParams.get('section'));
if (routeParams.get('view') === 'audit' && (routeParams.has('device') || routeParams.has('site'))) {
const tenant = routeParams.get('tenant') || '当前租户';
const site = routeParams.get('site') || '当前站点';
const device = routeParams.get('device') || '全部设备';
$('#auditContext').hidden = false;
$('#auditTenant').textContent = tenant;
$('#auditSite').textContent = site;
$('#auditDevice').textContent = device;
$('#auditSearch').value = device;
auditRecords.unshift({time:'10:47:36',role:'站点管理员',action:'PAUSE_INFERENCE',object:device,result:'成功',badge:'success',source:'Sense outbox · relay 已送达'});
renderAuditPage();
$('#auditReturn').href = `../sense/index.html?view=device-detail&device=${encodeURIComponent(device)}&return_to=bell-audit`;
notify('已安全应用来自 Sense 的租户、站点和设备审计筛选。');
}
if (routeParams.has('event')) {
switchView('events');
showEventDetail(routeParams.get('event'));
}
if (routeParams.has('alert')) openAlert(routeParams.get('alert'));
if (routeParams.has('camera')) {
switchView('rules');
$('#ruleContext').hidden = false;
const camera = routeParams.get('camera');
if ([...$('#ruleCamera').options].some(option => option.value === camera)) $('#ruleCamera').value = camera;
const zone = routeParams.get('zone');
if (zone && [...$('#ruleZone').options].some(option => option.value === zone)) $('#ruleZone').value = zone;
$('#ruleContextCamera').textContent = $('#ruleCamera').selectedOptions[0].textContent;
$('#ruleContextZone').textContent = zone || $('#ruleZone').value;
syncSenseZoneLink();
notify('已保留摄像头与检测区域上下文,可继续配置规则。');
}
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -19,7 +19,7 @@
推荐标签:
- 类型:`kind/task` 标识可执行任务,并从 `type/docs`、`type/code` 中选择一个主要变更类型。
- 状态:`status/todo`、`status/doing`、`status/blocked`、`status/review`、`status/done`。
- 状态:`status/waiting`、`status/todo`、`status/doing`、`status/blocked`、`status/review`、`status/done`。
- 优先级:`priority/p0`、`priority/p1`、`priority/p2`。
`status/*`、`type/*`、`priority/*` 分别使用 Gitea exclusive scoped labels,同一分组任一时刻最多一个;`kind/task` 为普通标签。
@@ -28,6 +28,7 @@
| 阶段 | 任务文件 | Issue | 分支 / PR |
| --- | --- | --- | --- |
| 等待条件 | `TODO` | open + `status/waiting` | 无 claim;依赖或外部条件满足后由 dispatcher 转为 todo |
| 待领取 | `TODO` | open + `status/todo` | 无 claim 分支 |
| 开发中 | 工作分支上 `DOING` | open + `status/doing` | claim 与工作分支存在 |
| 阻塞 | `BLOCKED` | open + `status/blocked` | 默认保留 claim,避免误领 |
@@ -40,9 +41,9 @@ Issue 是实时状态权威;默认分支尚未合入工作提交时,其任
1. 先创建任务文件,写清规格、依赖和初始 `write_paths`,合入默认分支;此时 `issue`、`context_ref`、claim / 工作分支均为 `null`。
2. 用 Issue 模板创建唯一主 Issue。新 Issue 只有 `kind/task`,尚未带 `status/todo`。
3. 把 Issue 编号回填任务文件,并让 Issue 链接该文件;映射提交合入默认分支后,再选择 `type/*`、`priority/*` 和 `status/todo`。
3. 把 Issue 编号回填任务文件,并让 Issue 链接该文件;映射提交合入默认分支后,再选择 `type/*`、`priority/*`。依赖已全部 `DONE` 且未被人工暂缓时使用 `status/todo`,否则使用 `status/waiting`。
因此 dispatcher 能从默认分支可靠定位任务 ↔ Issue;没有双向映射或没有 `status/todo` 的任务都不可领取。
因此 dispatcher 能从默认分支可靠定位任务 ↔ Issue;没有双向映射或不是 `status/todo` 的任务都不可领取。`status/waiting` 不占用 claim,恢复条件必须写在 Issue 评论或任务执行记录中。
## 串行分配与防重复领取
+1 -1
View File
@@ -25,7 +25,7 @@
| 领域 | 评级 | 验证状态 | Agent 可读性 | 测试稳定性 | 关键缺口 | 上次更新 |
|------|------|---------|-------------|-----------|---------|---------|
| Harness 文档与任务治理 | A | 三条治理命令通过 | A | A | 需观察真实多轮任务是否顺畅 | 2026-08-03 |
| 摄像头接入与媒体 | N/A | M0/M1 未完成 | B(设计清楚) | N/A | 实机兼容矩阵、Sense 代码与 5 路 smoke | 2026-08-03 |
| 摄像头接入与媒体 | C | M0 单海康指定基线已准入,M1 未完成 | B(设计与单实机证据清楚) | B(单次 605 秒双码流) | T-006 单实机混合五路 smoke、真实断网恢复;生产前补 T-007 真实多路现场证据 | 2026-08-05 |
| 推理与事件契约 | C | 契约已冻结,生产者未实现 | A | N/A | Brain mapper、契约测试和硬件基准 | 2026-08-03 |
| 规则、预警与处置 | N/A | M3 未开始 | B(设计清楚) | N/A | Bell、ack/升级、投递与 UI | 2026-08-03 |
| 多租户、RBAC 与审计 | N/A | M3 未开始 | B(设计清楚) | N/A | schema、鉴权和隔离测试 | 2026-08-03 |
+15 -7
View File
@@ -125,8 +125,8 @@ erDiagram
| --- | --- | --- |
| **Tenant 租户** | 计费与数据隔离的最小单位 | 所有查询必须带 tenant_id,无例外 |
| **Site 站点** | 一个物理场所(一户 / 一所学校 / 一个小区) | 升级链、时段策略挂在这一层 |
| **Area 区域** | 站点内的逻辑分区(教学楼 / 单元 / 客厅) | 用于权限范围与报表口径 |
| **Device 设备** | **不叫 Camera**,`device_type` 取 camera/radar/door/button | 为异构传感器预留(继承参考项目 §4.5) |
| **Area 区域** | 站点内的逻辑分区(教学楼 / 单元 / 客厅),由 Bell 统一管理 | 用于权限范围、报表口径与设备准入;`capture_policy` 表达是否仅允许非成像设备,Sense 只消费带版本的策略投影 |
| **Device 设备** | **不叫 Camera**;`modality` 表达 video/radar/contact/button/wearable/other,`capabilities` 表达成像、媒体、遥测等具体能力 | 模态负责稳定分类,能力负责决定字段与操作,避免接入新设备时重做台账和页面 |
| **Zone 画面区域** | 画在某路画面上的多边形或警戒线 | 与 Area 是两回事:Area 是物理概念,Zone 是像素坐标 |
| **Rule 规则** | 「主体 × 条件 × 时空 × 动作」的可配置组合 | 三级覆盖:租户默认 → 站点 → 设备 |
| **Event 事件** | 一次被判定成立的客观发生 | **不可变**。误判只能被标记,不能被删改 |
@@ -608,21 +608,27 @@ GPU 型号和数量不在需求阶段写死。M1 用 5 路打通,M3 以 16 路
在参考项目基础上扩展多租户与规则/事件。**字段级设计见《03》,此处只给结构与关键约束。**
```sql
-- ── 组织(Bell 为真相源;Sense 只读配额投影)────────
-- ── 组织(Bell 为真相源;Sense 不直接写这些表)────────
tenants(id, code, name, status, plan, created_at)
sites(id, tenant_id, code, name, scene_pack, subnet CIDR, timezone, status,
max_video_channels INT NOT NULL DEFAULT 16 CHECK (max_video_channels BETWEEN 1 AND 128))
areas(id, site_id, name, parent_id)
areas(id, site_id, name, parent_id,
capture_policy) -- video_allowed | non_imaging_only
-- ── 设备(不叫 cameras,为异构传感器预留)────
-- ── Sense 只读投影(来源版本与同步时间必须可追溯)────
site_quota_projections(site_id, max_video_channels, source_version, synced_at)
area_policy_projections(area_id, site_id, capture_policy, source_version, synced_at)
-- ── Sense 设备(不叫 cameras,为异构传感器预留)────
devices(
id, tenant_id, site_id, area_id,
device_type, -- camera | radar | door | button
modality, -- video | radar | contact | button | wearable | other
device_kind, -- camera | mmwave_radar | door_contact | panic_button | ...
capabilities JSONB, -- captures_image / media_stream / telemetry / battery / spatial_config / ...
serial UNIQUE, -- ⚠️ 身份用序列号,不用 IP
vendor, model,
onvif_addr, onvif_user, onvif_secret, -- secret 加密存储
state, -- pending_activation | active | offline | disabled
privacy_flag, -- 隐私区域标记,为 true 时拒绝 camera 类型
last_seen_at
)
stream_bindings(id, device_id, mtx_instance, inference_shard, path_name, rtsp_url, profile_token, enabled,
@@ -701,6 +707,8 @@ CREATE INDEX ON alerts(state) WHERE state NOT IN ('closed','suppressed');
7. 人脸相关表独立 schema、独立加密、独立审计;`persons.valid_until` 必填,无"永久有效"选项
8. `sites.max_video_channels` 默认 16、最大 128;配额在写入设备时校验,分片容量由运行时配置与压测决定
9. `sites.max_video_channels` 由 Bell 持有,Sense 通过版本化内部 API 或只读投影校验设备新增/启用;不得跨 schema 直接写入
10. Tenant/Site/Area/RBAC、配额与全局审计属于 Bell;Sense 仅使用稳定逻辑 ID 关联并保存带 `source_version` / `synced_at` 的执行投影
11. 高风险设备写操作与本地审计 outbox 在 Sense 同一事务提交,再由幂等 relay 异步汇入 Bell;审计接口字段、签名和重放规则另立契约任务冻结
---
+8 -5
View File
@@ -921,7 +921,7 @@ scene_packs/campus@1.2/
| 约束 | 实现 |
| --- | --- |
| 卧室、卫生间**禁装摄像头** | `devices.privacy_flag = true` 时系统拒绝创建 `device_type = camera`(代码级硬约束) |
| 卧室、卫生间**禁装摄像头** | `areas.capture_policy = non_imaging_only` 时,Sense 在设备新增/启用写路径拒绝具有 `captures_image` 能力的设备(代码级硬约束);策略读取失败时拒绝新变更并告警,不静默切断已有链路 |
| 平时不解码不上传 | 触发式推理 + `record: no` + 边缘环形缓冲 |
| 家属只能看自己家的告警关联片段 | RBAC:`family` 角色的证据访问范围 = `site_id` ∩ `alert_events` |
| 3 分钟内必有真人接手 | 4 级升级链(30s/60s/90s → 呼叫中心) |
@@ -1067,7 +1067,7 @@ capacity:
`stream_bindings` 同时记录 `mtx_instance` 与 `inference_shard`。默认按 `site_id + device_id` 稳定分配;扩容时允许迁移,但同一设备任一时刻只能归属一个有效媒体分片和一个推理分片。
`sites.max_video_channels` 的唯一真相源在 Bell;Sense 在设备新增/启用写路径通过版本化内部 API 或只读投影执行配额校验,不跨 schema 直接写入。配额依赖暂时不可用时只拒绝新的容量变更,已有视频链路继续运行。
`sites.max_video_channels`、Site/Area 层级和 `areas.capture_policy` 的唯一真相源在 Bell;Sense 在设备新增/启用写路径通过版本化内部 API 或带 `source_version` / `synced_at` 的只读投影执行校验,不跨 schema 直接写入。配额或 Area 策略依赖暂时不可用时只拒绝相关新变更,已有视频链路继续运行并产生运维告警。
> **不要单实例硬扛 128 路**:扩容必须通过增加分片与 Worker 完成。单分片故障只允许影响该分片,不能拖垮整个站点。GPU 型号与数量不在架构文档写死,M1 用 5 路打通,M3 建立 16 路基线,M4/M5 再验证 64/128 路。
@@ -1115,7 +1115,8 @@ OpenTelemetry → Jaeger,排查单帧处理延迟与端到端事件链路。
| 端 | 核心功能 |
| --- | --- |
| **管理系统(Web)** | 租户/站点/设备管理、批量开通、规则配置(含试运行)、Zone 画图、升级链配置、事件看板与筛选、事件详情(视频+观测回放)、处置与工单、误报标记、统计报表、审计查询、RBAC |
| **Sense 接入工作台(Web)** | 当前租户/站点上下文、设备台账与批量开通、能力探测、Zone 画图、对账/分片/边缘隧道/补传/运维告警;不管理 Tenant/Site/Area/RBAC 或全局审计 |
| **Bell 统一管理端(Web)** | Tenant/Site/Area/配额/`capture_policy`、RBAC、规则配置(含试运行)、升级链、事件看板与筛选、事件详情(视频+观测回放)、处置与工单、误报标记、统计报表、全局审计;设备操作日志从 Sense 汇入并可深链返回 |
| **App(家属/值班员)** | 预警接收与 ack、事件详情与视频回看、一键呼叫、误报反馈、限时静默、设备状态、(家属)多老人切换 |
| **大屏/值班台** | 实时事件流、地图/平面图点位、声光联动、当班交接 |
| **Webhook/开放 API** | 对接客户既有平台、门禁、消防、工单系统 |
@@ -1144,7 +1145,9 @@ OpenTelemetry → Jaeger,排查单帧处理延迟与端到端事件链路。
| --- | --- | --- | --- | --- | --- |
| **Sense** | 感知系统 | L1 接入 | Go | 有(设备台账) | M1 |
| **Brain** | 推理系统 | L2 流水线 + L3 算法 | Python / CUDA | **无状态** | M3 |
| **Bell** | 管理系统 | L4 业务 + L0 基座 | Go + 前端 | 有(事件、告警) | M3 → M4 |
| **Bell** | 管理系统 | L4 业务 + L0 基座 | Go + 前端 | 有(事件、告警、组织与策略真相) | M3 → M4 |
Sense 的高风险设备操作不能采用“先执行、再尽力记录审计”。实现时应在本地事务内同时写期望态与持久化 outbox,再由幂等 relay 异步汇入 Bell 全局审计。该跨系统接口的字段、签名、重放和留存必须另立 API/契约任务,不在 UI 原型任务中顺带冻结。
三者之间只有一条契约——**事件实例 JSON**(§2.6,规范本体见 `contracts/event-v0.1.schema.json`)。Sense 向 Brain 供流与信号,Brain 产出事件,Bell 消费事件。这条边界让推理侧换模型、换框架(Savant → Pipeless、YOLO → YOLOX)时,另两个系统一行不改。
@@ -1228,7 +1231,7 @@ redis 消息总线
- [ ] 终止服务时的底库彻底删除义务与证明方式已约定
## 架构
- [ ] `devices` 表而非 `cameras`,含 `device_type`
- [ ] `devices` 表而非 `cameras`,含 `modality + capabilities`;Bell 持有 Area 与 `capture_policy`,Sense 只存带版本的准入投影
- [ ] 设备身份用序列号
- [ ] 触发入口独立成 handler
- [ ] `sync_state` 每系统一行
+10 -7
View File
@@ -23,7 +23,7 @@
**Sense —— 把现场的流和信号稳定地拿进来并管住**
- 设备台账(`devices`,含 `modality`:video / radar / contact / button / wearable)
- 设备台账(`devices`,含 `modality`:video / radar / contact / button / wearable / other,以及决定成像、媒体、遥测、空间配置等页面与操作的 `capabilities`)
- ONVIF 客户端:连接、GetProfiles、GetStreamUri、SetSystemDateAndTime
- mediamtx API 客户端(**自行用 oapi-codegen 从其 OpenAPI 生成**,不依赖第三方 SDK)
- **对账器**:水平触发、`sync_state`、独立信号量、10% 孤儿删除安全闸、只收敛不回滚
@@ -31,6 +31,7 @@
- WireGuard 控制面隧道
- mediamtx 鉴权回调(401 + 踢流是四种停用粒度之一)
- 容量配额执行:设备新增/启用时读取 Bell 的站点配额,只拒绝超额变更;配额服务暂时不可用时不影响已有流
- Area 准入执行:消费 Bell 的版本化 `capture_policy` 投影;策略不可用时只拒绝相关新写入,不静默中断已有设备
- 流绑定与媒体分片调度:维护 `mtx_instance`,默认 16 路交付;扩到 128 路时按 `media_shard.max_streams` 横向分片
- 边缘节点 agent:推流、本地环形缓冲、断网续传
- **设备型触发源**:雷达、门磁、按钮、ONVIF 事件订阅
@@ -54,8 +55,8 @@
- 告警状态机:升级链、ack、抑制、静默(**≤4h,无永久选项**)
- 投递:push / 短信 / 语音,**双供应商**
- 误报反馈闭环:`outcome` 回写 Brain
- 多租户 RBAC、`tenant_features`(人脸授权开关:未授权时能力在 API 与 UI 中**不可见**,不是禁用)
- 审计日志
- Tenant/Site/Area、`capture_policy`、多租户 RBAC、`tenant_features`(人脸授权开关:未授权时能力在 API 与 UI 中**不可见**,不是禁用)
- 全局审计日志;接收 Sense 设备操作审计并保留来源上下文
- 站点容量配额的唯一真相源:`site.max_video_channels` 默认 16、上限 128;提供版本化内部读取接口/投影给 Sense 执行
- 管理后台前端 + 大屏:按 128 路设计分页/虚拟列表、筛选与批量操作,不一次性加载全部视频
@@ -80,7 +81,7 @@ Sense/
│ ├── sense-api/ 控制面服务:设备台账、鉴权回调、对账器
│ └── sense-agent/ 边缘节点:推流、环形缓冲、隧道
├── internal/
│ ├── device/ 设备台账(devices,含 modality)
│ ├── device/ 设备台账(devices,含 modality + capabilities)
│ ├── onvif/ 连接、GetProfiles、GetStreamUri、SetSystemDateAndTime
│ ├── mtx/ oapi-codegen 生成的 mediamtx 客户端 + 薄封装
│ ├── reconcile/ 对账循环(幂等 + 退避 + 安全闸)
@@ -138,7 +139,7 @@ Bell/
---
## 3. 七条边界(不写死就会漂)
## 3. 八条边界(不写死就会漂)
| # | 边界 | 定论 |
| --- | --- | --- |
@@ -149,8 +150,10 @@ Bell/
| 5 | 一个 PostgreSQL 还是三个 | **一个实例,schema 分离**(`sense` / `bell`),Brain 无 schema。"DB 是唯一真相源"是对账器成立的前提,拆库即失效 |
| 6 | 16 路与 128 路分别指什么 | **16 路是默认交付规格,128 路是本阶段单逻辑站点上限**,都不是“单进程/单服务器保证值”。媒体与推理必须横向分片;单机承载量由分辨率、码率、帧率、模型和硬件基准测试决定 |
| 7 | 站点配额谁拥有、谁执行 | **Bell 拥有 `sites.max_video_channels`,Sense 在设备新增/启用写路径执行**。通过版本化内部 API 或只读投影同步,不允许 Sense 直接写 Bell schema;依赖暂时不可用时拒绝新变更,但已有视频链路继续运行 |
| 8 | 设备模态与隐私准入怎么建模 | 台账使用 `modality + capabilities`,不以摄像头作为唯一根实体;**Bell 拥有 Area 与 `capture_policy`,Sense 消费带版本投影并按设备 `captures_image` 能力在新增/启用写路径执行**。策略不可用时拒绝相关新变更并告警,已有链路不静默停用;M1 固化模型,M6 再增加非视频适配器 |
| 9 | 接入操作审计怎么进入 Bell | Sense 在同一事务写设备期望态和持久化 outbox,再由幂等 relay 异步汇入 Bell 全局审计;不得先执行高风险操作再尽力入队。字段、签名、重放和留存由独立 API/契约任务冻结 |
第 2、3、6、7 条是本文档新定的,若实施中发现更合适的切法,改这里并同步《02》《03》。
第 2、3、6、7、8、9 条是本文档新定的,若实施中发现更合适的切法,改这里并同步《02》《03》。
---
@@ -179,6 +182,6 @@ DB schema sense — bell
| **M3** 默认 16 路推理 + 规则引擎 + 事件预警 | **Brain + Bell 同时起步**,契约在此首次被真实使用 |
| **M4** 64 路分片 + 灰度 + 管理系统 | Sense 与 Brain 完成横向分片,Bell 完成 128 路规模下的列表与批量交互;验证单分片故障隔离 |
| **M5** 128 路容量验收 + 第二三场景包 + 触发式推理 + 人脸 | Sense/Brain 验证扩容不改业务代码;Bell 的 `packs/`(**纯配置**)+ Brain 的模型与触发 |
| **M6** 异构传感器 + 两级判定 | Sense 的 `trigger/` 与 `device/`(modality 扩展)+ Brain 两级判定 |
| **M6** 异构传感器 + 两级判定 | Sense 的 `trigger/` 与 `device/` 增加非视频协议适配器与能力实现(`modality + capabilities` 模型已从 M1 保留)+ Brain 两级判定 |
> M3 是契约第一次被真实使用的时刻。**在此之前契约允许原地修订,之后版本递增规则绝对生效**(契约 README §1)。

Some files were not shown because too many files have changed in this diff Show More