From 67391acb16f83f66e48233ac65b13cd2dba54839 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 31 Aug 2026 08:48:18 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=86=BB=E7=BB=93=E6=BA=90?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=A5=91=E7=BA=A6=20v1=20(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/source-config/v1/README.md | 35 +++ contracts/source-config/v1/compatibility.md | 29 ++ .../invalid/coordinate-out-of-range.json | 13 + .../v1/examples/invalid/credential-field.json | 13 + .../v1/examples/invalid/expected-errors.json | 8 + .../v1/examples/invalid/internal-path.json | 13 + .../invalid/profile-binding-mismatch.json | 13 + .../v1/examples/invalid/query-token.json | 13 + .../invalid/unknown-major-version.json | 13 + .../v1/examples/valid/active.json | 62 ++++ .../valid/recalibration-required.json | 39 +++ contracts/source-config/v1/mapper-fields.md | 35 +++ .../v1/source-config.schema.json | 273 ++++++++++++++++++ .../tests/source-config-v1/requirements.txt | 2 + contracts/tests/source-config-v1/run.ps1 | 33 +++ .../source-config-v1/test_source_config_v1.py | 261 +++++++++++++++++ 16 files changed, 855 insertions(+) create mode 100644 contracts/source-config/v1/README.md create mode 100644 contracts/source-config/v1/compatibility.md create mode 100644 contracts/source-config/v1/examples/invalid/coordinate-out-of-range.json create mode 100644 contracts/source-config/v1/examples/invalid/credential-field.json create mode 100644 contracts/source-config/v1/examples/invalid/expected-errors.json create mode 100644 contracts/source-config/v1/examples/invalid/internal-path.json create mode 100644 contracts/source-config/v1/examples/invalid/profile-binding-mismatch.json create mode 100644 contracts/source-config/v1/examples/invalid/query-token.json create mode 100644 contracts/source-config/v1/examples/invalid/unknown-major-version.json create mode 100644 contracts/source-config/v1/examples/valid/active.json create mode 100644 contracts/source-config/v1/examples/valid/recalibration-required.json create mode 100644 contracts/source-config/v1/mapper-fields.md create mode 100644 contracts/source-config/v1/source-config.schema.json create mode 100644 contracts/tests/source-config-v1/requirements.txt create mode 100644 contracts/tests/source-config-v1/run.ps1 create mode 100644 contracts/tests/source-config-v1/test_source_config_v1.py diff --git a/contracts/source-config/v1/README.md b/contracts/source-config/v1/README.md new file mode 100644 index 0000000..b40c82e --- /dev/null +++ b/contracts/source-config/v1/README.md @@ -0,0 +1,35 @@ +# Sense → Brain 媒体源与区域规则配置契约 v1 + +`yovision.source-config/v1` 是 Sense 发布、Brain 消费的完整配置快照。它只携带稳定逻辑标识、无凭据媒体引用、Profile 规格、归一化规则及完整性摘要,不暴露 Sense 数据库模型或 Brain 内部配置模型。 + +本版本选择 JSON Schema,而不是 OpenAPI:快照可经文件、消息或后续 connector 传输,工单 #148 不定义 HTTP 端点。后续 connector 若提供 HTTP API,应引用本 Schema,不复制字段定义。 + +## 文件 + +- `source-config.schema.json`:Draft 2020-12 JSON Schema。 +- `examples/valid/`:可接受的 active 与待重校准快照。 +- `examples/invalid/`:必须安全拒绝的版本、秘密、路径、坐标和绑定错误。 +- `compatibility.md`:版本、兼容周期、迁移和回退规则。 +- `mapper-fields.md`:Sense 生产者与 Brain 消费者字段映射和测试责任。 + +## 消费规则 + +1. 先按 JSON Schema 校验,再执行跨字段语义校验。 +2. `schema` 必须精确等于 `yovision.source-config/v1`;未知主版本不得降级猜测。 +3. `ruleSet.profileBinding` 必须与 `profile.id/width/height` 完全一致。 +4. `ruleSet.state != active` 时不得运行任何规则;`recalibration_required` 表示 Profile 规格变化后需重新标定。 +5. `areas` 与 `directionalLines` 的 `id` 在同一快照内必须全局唯一;多边形必须非退化,线段起终点不得相同。 +6. `effectiveAt` 不得早于 `publishedAt`。 +7. `integrity.value` 是移除顶层 `integrity` 后,对 RFC 8785 JCS 规范化 JSON 字节计算的 SHA-256 小写十六进制摘要。生产消费者应使用合规 JCS 实现;仓库样例只使用 JCS 简单类型子集。 + +`media.ref` 是 connector 解析的无凭据不透明引用,固定以 `media:` 开头。它不能包含 URI authority、用户名、密码、查询参数、fragment、Windows 盘符或文件系统路径。RTSP 凭据交换与机器身份不属于本契约。 + +## 可复制验证 + +从仓库根目录运行: + +```powershell +& contracts\tests\source-config-v1\run.ps1 +``` + +脚本在系统临时目录创建隔离虚拟环境、安装固定版本的 Schema 校验器并运行测试,不修改产品目录。测试结束后会清理临时环境。 diff --git a/contracts/source-config/v1/compatibility.md b/contracts/source-config/v1/compatibility.md new file mode 100644 index 0000000..f7546c7 --- /dev/null +++ b/contracts/source-config/v1/compatibility.md @@ -0,0 +1,29 @@ +# v1 兼容、迁移与回退 + +## 兼容规则 + +- v1 发布后只允许在预留的顶层 `extensions` 对象中增加命名空间化、非秘密的可选扩展。消费者必须忽略自己不认识的扩展命名空间,但仍须拒绝当前 Schema 或语义规则标记为非法的输入;发布扩展时应同步生产者/消费者测试。v1 核心对象保持封闭,不能通过新增核心字段规避新主版本。 +- 删除字段、把可选改为必填、收紧已发布取值范围,或改变字段类型、单位、坐标系、Profile 绑定、revision、状态及媒体引用语义,均为破坏性变化,必须发布新主版本目录和新的 `schema` 值。 +- 未知主版本必须安全拒绝并保留最后一个已验证配置。不得把未知版本转换成 v1,也不得继续启用来自未知版本的规则。 +- v1 的坐标始终是相对于 `profile.width × profile.height` 图像平面的 0–1 归一化坐标;原点在左上,x 向右、y 向下。该语义不得在 v1 内改变。 + +## revision 与生效 + +- `(configId, revision)` 唯一标识一个不可变快照;同一 `configId` 的新发布必须使用严格递增的 `revision`。 +- 消费者仅在 Schema、语义和完整性均通过后,按 `effectiveAt` 原子切换整个快照。重复收到同一 revision 应幂等处理;更小 revision 应拒绝为陈旧配置。 +- Profile ID、分辨率或编码变化时,生产者必须发布新 revision。已有几何尚未按新 Profile 校准时,必须设置 `ruleSet.state = recalibration_required`;消费者不得启用其中规则。 +- 新 revision 校验失败或未到生效时间时,消费者保留上一份已验证且仍有效的 active revision。 + +## 支持周期 + +- 发布新主版本后,Sense 生产者与 Brain 消费者至少并行支持上一主版本一个正式发布周期,且不少于 90 天;具体停止日期必须在新版本协调工单中冻结。 +- 并行期内生产者按目标消费者能力选择版本,不得把两个主版本字段混在同一快照。 + +## 回退 + +1. 停止分发有问题的新主版本或新 revision。 +2. 重新发布上一主版本的最后一个已验证快照;若仍为同一 `configId`,必须使用该主版本下新的、更大 revision,不能覆盖历史 revision。 +3. Brain 通过完整 Schema、语义和摘要校验后原子切回;切换前继续使用最后一个有效快照,或在没有有效快照时保持规则停用。 +4. 记录失败版本和拒绝原因,但不得记录媒体凭据或完整客户配置。 + +样例 `examples/valid/recalibration-required.json` 展示 Profile 变化后的安全停用状态。回退不修改已发布 v1 字段语义,也不要求读取 Sense 数据库。 diff --git a/contracts/source-config/v1/examples/invalid/coordinate-out-of-range.json b/contracts/source-config/v1/examples/invalid/coordinate-out-of-range.json new file mode 100644 index 0000000..dce977f --- /dev/null +++ b/contracts/source-config/v1/examples/invalid/coordinate-out-of-range.json @@ -0,0 +1,13 @@ +{ + "schema": "yovision.source-config/v1", + "configId": "school-east-entry-01", + "revision": 7, + "publishedAt": "2026-08-31T00:10:00Z", + "effectiveAt": "2026-08-31T00:15:00Z", + "site": {"id": "site-east"}, + "logicalDevice": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"}, + "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [{"id": "bad-area", "version": 1, "kind": "danger_area", "enabled": true, "points": [{"x": 0, "y": 0}, {"x": 1.2, "y": 0}, {"x": 0, "y": 1}]}], "directionalLines": []}, + "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} +} diff --git a/contracts/source-config/v1/examples/invalid/credential-field.json b/contracts/source-config/v1/examples/invalid/credential-field.json new file mode 100644 index 0000000..e58181a --- /dev/null +++ b/contracts/source-config/v1/examples/invalid/credential-field.json @@ -0,0 +1,13 @@ +{ + "schema": "yovision.source-config/v1", + "configId": "school-east-entry-01", + "revision": 7, + "publishedAt": "2026-08-31T00:10:00Z", + "effectiveAt": "2026-08-31T00:15:00Z", + "site": {"id": "site-east"}, + "logicalDevice": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp", "password": null}, + "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directionalLines": []}, + "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} +} diff --git a/contracts/source-config/v1/examples/invalid/expected-errors.json b/contracts/source-config/v1/examples/invalid/expected-errors.json new file mode 100644 index 0000000..a2f1a90 --- /dev/null +++ b/contracts/source-config/v1/examples/invalid/expected-errors.json @@ -0,0 +1,8 @@ +{ + "coordinate-out-of-range.json": "schema", + "credential-field.json": "secret", + "internal-path.json": "internal path", + "profile-binding-mismatch.json": "profile binding", + "query-token.json": "secret", + "unknown-major-version.json": "unknown schema" +} diff --git a/contracts/source-config/v1/examples/invalid/internal-path.json b/contracts/source-config/v1/examples/invalid/internal-path.json new file mode 100644 index 0000000..80adf98 --- /dev/null +++ b/contracts/source-config/v1/examples/invalid/internal-path.json @@ -0,0 +1,13 @@ +{ + "schema": "yovision.source-config/v1", + "configId": "school-east-entry-01", + "revision": 7, + "publishedAt": "2026-08-31T00:10:00Z", + "effectiveAt": "2026-08-31T00:15:00Z", + "site": {"id": "site-east"}, + "logicalDevice": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "media": {"ref": "C:\\customers\\school-east\\camera-01", "transport": "rtsp"}, + "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directionalLines": []}, + "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} +} diff --git a/contracts/source-config/v1/examples/invalid/profile-binding-mismatch.json b/contracts/source-config/v1/examples/invalid/profile-binding-mismatch.json new file mode 100644 index 0000000..2189566 --- /dev/null +++ b/contracts/source-config/v1/examples/invalid/profile-binding-mismatch.json @@ -0,0 +1,13 @@ +{ + "schema": "yovision.source-config/v1", + "configId": "school-east-entry-01", + "revision": 7, + "publishedAt": "2026-08-31T00:10:00Z", + "effectiveAt": "2026-08-31T00:15:00Z", + "site": {"id": "site-east"}, + "logicalDevice": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"}, + "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1280, "height": 720}, "areas": [], "directionalLines": []}, + "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} +} diff --git a/contracts/source-config/v1/examples/invalid/query-token.json b/contracts/source-config/v1/examples/invalid/query-token.json new file mode 100644 index 0000000..f8e204c --- /dev/null +++ b/contracts/source-config/v1/examples/invalid/query-token.json @@ -0,0 +1,13 @@ +{ + "schema": "yovision.source-config/v1", + "configId": "school-east-entry-01", + "revision": 7, + "publishedAt": "2026-08-31T00:10:00Z", + "effectiveAt": "2026-08-31T00:15:00Z", + "site": {"id": "site-east"}, + "logicalDevice": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "media": {"ref": "media:site-east/entry-01/main?token=", "transport": "rtsp"}, + "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directionalLines": []}, + "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} +} diff --git a/contracts/source-config/v1/examples/invalid/unknown-major-version.json b/contracts/source-config/v1/examples/invalid/unknown-major-version.json new file mode 100644 index 0000000..9b231fb --- /dev/null +++ b/contracts/source-config/v1/examples/invalid/unknown-major-version.json @@ -0,0 +1,13 @@ +{ + "schema": "yovision.source-config/v2", + "configId": "school-east-entry-01", + "revision": 7, + "publishedAt": "2026-08-31T00:10:00Z", + "effectiveAt": "2026-08-31T00:15:00Z", + "site": {"id": "site-east"}, + "logicalDevice": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"}, + "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directionalLines": []}, + "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} +} diff --git a/contracts/source-config/v1/examples/valid/active.json b/contracts/source-config/v1/examples/valid/active.json new file mode 100644 index 0000000..0f2f408 --- /dev/null +++ b/contracts/source-config/v1/examples/valid/active.json @@ -0,0 +1,62 @@ +{ + "schema": "yovision.source-config/v1", + "configId": "school-east-entry-01", + "revision": 7, + "publishedAt": "2026-08-31T00:10:00Z", + "effectiveAt": "2026-08-31T00:15:00Z", + "site": { + "id": "site-east" + }, + "logicalDevice": { + "id": "entry-camera-01" + }, + "profile": { + "id": "main-stream", + "width": 1920, + "height": 1080, + "encoding": "H264", + "frameRate": 25 + }, + "media": { + "ref": "media:site-east/entry-01/main", + "transport": "rtsp" + }, + "ruleSet": { + "version": "entry-rules-7", + "state": "active", + "profileBinding": { + "profileId": "main-stream", + "width": 1920, + "height": 1080 + }, + "areas": [ + { + "id": "danger-yard", + "version": 3, + "kind": "danger_area", + "enabled": true, + "points": [ + {"x": 0.12, "y": 0.18}, + {"x": 0.82, "y": 0.18}, + {"x": 0.76, "y": 0.78}, + {"x": 0.18, "y": 0.72} + ] + } + ], + "directionalLines": [ + { + "id": "entry-line", + "version": 2, + "kind": "directional_line", + "enabled": true, + "start": {"x": 0.2, "y": 0.5}, + "end": {"x": 0.8, "y": 0.5}, + "triggerDirection": "left_to_right" + } + ] + }, + "integrity": { + "algorithm": "sha256", + "value": "b35a4f345162b059d6912cca6340a6714826b7921662f4f28a91ef29c5476cb8" + } +} diff --git a/contracts/source-config/v1/examples/valid/recalibration-required.json b/contracts/source-config/v1/examples/valid/recalibration-required.json new file mode 100644 index 0000000..691b661 --- /dev/null +++ b/contracts/source-config/v1/examples/valid/recalibration-required.json @@ -0,0 +1,39 @@ +{ + "schema": "yovision.source-config/v1", + "configId": "school-east-entry-01", + "revision": 8, + "publishedAt": "2026-08-31T01:00:00Z", + "effectiveAt": "2026-08-31T01:00:00Z", + "site": { + "id": "site-east" + }, + "logicalDevice": { + "id": "entry-camera-01" + }, + "profile": { + "id": "main-stream-v2", + "width": 1280, + "height": 720, + "encoding": "H265", + "frameRate": 20 + }, + "media": { + "ref": "media:site-east/entry-01/main-v2", + "transport": "rtsp" + }, + "ruleSet": { + "version": "entry-rules-8", + "state": "recalibration_required", + "profileBinding": { + "profileId": "main-stream-v2", + "width": 1280, + "height": 720 + }, + "areas": [], + "directionalLines": [] + }, + "integrity": { + "algorithm": "sha256", + "value": "04408f00740558c70d9d1e3f9f5efbf03e6f22ccc0ba4ec6b6c8688df7b568df" + } +} diff --git a/contracts/source-config/v1/mapper-fields.md b/contracts/source-config/v1/mapper-fields.md new file mode 100644 index 0000000..73cdfea --- /dev/null +++ b/contracts/source-config/v1/mapper-fields.md @@ -0,0 +1,35 @@ +# 生产者与消费者 mapper 字段表 + +mapper 必须创建新的契约 DTO,不得直接序列化 Sense GORM 实体,也不得让 Brain 把共享快照当作 `brain.internal.input/v1`。 + +| 契约字段 | Sense 生产来源/规则 | Brain 消费目标/规则 | +|---|---|---| +| `schema` | 常量 `yovision.source-config/v1` | 在任何映射前精确校验;未知主版本拒绝 | +| `configId` | 新的稳定配置聚合 ID;不是数据库行 ID 语义 | 作为配置流逻辑 ID,不解释为 Brain 内部对象 ID | +| `revision` | 聚合配置变更时严格递增;不可复用 | 与 `configId` 共同做幂等、顺序和陈旧检查 | +| `publishedAt` / `effectiveAt` | 发布时写 UTC RFC 3339;生效不得早于发布 | 完整校验后按生效时间原子切换 | +| `site.id` | 对外稳定站点引用;不得映射客户名或数据库主键语义 | 仅作租户隔离后的逻辑关联;v1 不提供用户身份 | +| `logicalDevice.id` | `area.Definition.DeviceID` / `media.Route.DeviceID` 经稳定外部 ID mapper | 映射到 `BrainInputConfig.logical_device_id` | +| `profile.id` | `area.Definition.ProfileToken` 与 `media.Route.ProfileToken` 经稳定 Profile ID mapper | 映射到 `BrainInputConfig.profile.profile_id` | +| `profile.width/height/encoding` | `area.Definition.ProfileWidth/ProfileHeight/ProfileEncoding`;必须与当前媒体 Profile 一致 | 映射到规则 `RuleSet` 的 Profile 绑定;不一致拒绝 | +| `profile.frameRate` | Sense 已验证 Profile 的帧率快照 | 映射到 `BrainInputConfig.profile.fps` | +| `media.ref` | 由 `media.Route.ID/Path` 生成 `media:`;禁止读取或拼入 `admissionProfile.StreamURI` 及凭据 | 交给后续 connector 解析;不得当作 RTSP URL 或本地路径 | +| `media.transport` | 当前固定 `rtsp`,仅描述媒体传输类别 | 选择后续 connector/decode adapter;不含认证信息 | +| `ruleSet.version` | 由一组 `area.Version` 聚合成稳定规则集版本 | 映射到 Brain `RuleSet.version` | +| `ruleSet.state` | `NeedsRecalibration=true` → `recalibration_required`;整体禁用 → `disabled`;否则 `active` | 只有 `active` 可构建并启用规则引擎 | +| `ruleSet.profileBinding` | 与本快照 `profile.id/width/height` 同源复制并交叉校验 | 必须精确等于 `profile`;之后才接受归一化几何 | +| `ruleSet.areas[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `AreaRule.rule_id`;version 用于可追溯性 | +| `ruleSet.areas[].kind` | Sense `polygon` 映射为 `danger_area` | 只映射到 Brain 危险区域规则,不透传 Sense 枚举 | +| `ruleSet.areas[].points` | `area.Version.GeometryJSON` 中 `{x,y}`;保持 0–1 | 映射到 Brain `Point(x,y)`;至少三点且非退化 | +| `ruleSet.directionalLines[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `DirectionalLineRule.rule_id` | +| `ruleSet.directionalLines[].start/end` | `direction_line` 几何的两个归一化点 | 映射到 Brain `Point`;相同点拒绝 | +| `ruleSet.directionalLines[].triggerDirection` | Sense `forward/reverse` 必须由 mapper 根据已确认的起终点方向转换为 `left_to_right/right_to_left` | 映射到 `DirectionalLineRule.trigger_direction`;不得直接猜测枚举 | +| `integrity` | 对移除 `integrity` 的 JCS 快照计算 SHA-256 | 映射前重算并常量时间比较;失败保留上一有效 revision | + +## 测试责任 + +- Sense 生产者契约测试:从设备、媒体 Route、Profile 与区域版本 fixture 生成快照;断言字段映射、revision 递增、Profile 变化触发新 revision/待重校准、无秘密媒体引用、Schema/语义/摘要通过。 +- Brain 消费者契约测试:加载本目录有效与无效样例;断言版本拒绝、幂等/陈旧处理、Profile 绑定、坐标、规则 ID、状态门禁和摘要;再映射为 Brain 内部配置,证明共享 `schema` 不等于 `brain.internal.input/v1`。 +- 协调契约测试(本工单):校验所有样例、秘密字段/URL/本地路径拒绝、跨字段语义和摘要。产品 adapter 测试在后续 connector 工单实施。 + +Sense 与 Brain 各自可增加内部字段,但不得将数据库主键、用户表、JWT、Cookie、摄像头凭据、客户内部路径或内部模型直接扩展进本契约。 diff --git a/contracts/source-config/v1/source-config.schema.json b/contracts/source-config/v1/source-config.schema.json new file mode 100644 index 0000000..cb1e2fe --- /dev/null +++ b/contracts/source-config/v1/source-config.schema.json @@ -0,0 +1,273 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://yovision.local/contracts/source-config/v1/source-config.schema.json", + "title": "YoVision Sense to Brain source configuration snapshot v1", + "description": "Credential-free media source, profile binding, and normalized rule configuration published by Sense for Brain.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "configId", + "revision", + "publishedAt", + "effectiveAt", + "site", + "logicalDevice", + "profile", + "media", + "ruleSet", + "integrity" + ], + "properties": { + "schema": { + "const": "yovision.source-config/v1" + }, + "configId": { + "$ref": "#/$defs/stableId" + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "publishedAt": { + "type": "string", + "format": "date-time" + }, + "effectiveAt": { + "type": "string", + "format": "date-time" + }, + "site": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { + "$ref": "#/$defs/stableId" + } + } + }, + "logicalDevice": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { + "$ref": "#/$defs/stableId" + } + } + }, + "profile": { + "$ref": "#/$defs/profile" + }, + "media": { + "type": "object", + "additionalProperties": false, + "required": ["ref", "transport"], + "properties": { + "ref": { + "type": "string", + "pattern": "^media:[A-Za-z0-9][A-Za-z0-9._~/-]{0,254}$", + "description": "Opaque credential-free reference resolved by the connector. URI authority, userinfo, query strings, and fragments are forbidden." + }, + "transport": { + "enum": ["rtsp"] + } + } + }, + "ruleSet": { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "state", + "profileBinding", + "areas", + "directionalLines" + ], + "properties": { + "version": { + "$ref": "#/$defs/stableId" + }, + "state": { + "enum": ["active", "disabled", "recalibration_required"] + }, + "profileBinding": { + "$ref": "#/$defs/profileBinding" + }, + "areas": { + "type": "array", + "items": { + "$ref": "#/$defs/areaRule" + }, + "maxItems": 1024 + }, + "directionalLines": { + "type": "array", + "items": { + "$ref": "#/$defs/directionalLineRule" + }, + "maxItems": 1024 + } + } + }, + "integrity": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "value"], + "properties": { + "algorithm": { + "const": "sha256" + }, + "value": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + }, + "extensions": { + "type": "object", + "description": "Optional namespaced, non-secret extension data. Consumers ignore unknown namespaces.", + "propertyNames": { + "pattern": "^[A-Za-z][A-Za-z0-9.-]{0,127}$" + }, + "additionalProperties": { + "type": "object" + } + } + }, + "$defs": { + "stableId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$" + }, + "positiveInteger": { + "type": "integer", + "minimum": 1 + }, + "positiveNumber": { + "type": "number", + "exclusiveMinimum": 0 + }, + "profile": { + "type": "object", + "additionalProperties": false, + "required": ["id", "width", "height", "encoding", "frameRate"], + "properties": { + "id": { + "$ref": "#/$defs/stableId" + }, + "width": { + "$ref": "#/$defs/positiveInteger" + }, + "height": { + "$ref": "#/$defs/positiveInteger" + }, + "encoding": { + "enum": ["H264", "H265", "MJPEG"] + }, + "frameRate": { + "$ref": "#/$defs/positiveNumber" + } + } + }, + "profileBinding": { + "type": "object", + "additionalProperties": false, + "required": ["profileId", "width", "height"], + "properties": { + "profileId": { + "$ref": "#/$defs/stableId" + }, + "width": { + "$ref": "#/$defs/positiveInteger" + }, + "height": { + "$ref": "#/$defs/positiveInteger" + } + } + }, + "point": { + "type": "object", + "additionalProperties": false, + "required": ["x", "y"], + "properties": { + "x": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "y": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + }, + "areaRule": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version", "kind", "enabled", "points"], + "properties": { + "id": { + "$ref": "#/$defs/stableId" + }, + "version": { + "$ref": "#/$defs/positiveInteger" + }, + "kind": { + "const": "danger_area" + }, + "enabled": { + "type": "boolean" + }, + "points": { + "type": "array", + "items": { + "$ref": "#/$defs/point" + }, + "minItems": 3, + "maxItems": 256 + } + } + }, + "directionalLineRule": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "version", + "kind", + "enabled", + "start", + "end", + "triggerDirection" + ], + "properties": { + "id": { + "$ref": "#/$defs/stableId" + }, + "version": { + "$ref": "#/$defs/positiveInteger" + }, + "kind": { + "const": "directional_line" + }, + "enabled": { + "type": "boolean" + }, + "start": { + "$ref": "#/$defs/point" + }, + "end": { + "$ref": "#/$defs/point" + }, + "triggerDirection": { + "enum": ["left_to_right", "right_to_left"] + } + } + } + } +} diff --git a/contracts/tests/source-config-v1/requirements.txt b/contracts/tests/source-config-v1/requirements.txt new file mode 100644 index 0000000..a90afd2 --- /dev/null +++ b/contracts/tests/source-config-v1/requirements.txt @@ -0,0 +1,2 @@ +jsonschema==4.23.0 +rfc8785==0.1.4 diff --git a/contracts/tests/source-config-v1/run.ps1 b/contracts/tests/source-config-v1/run.ps1 new file mode 100644 index 0000000..7ea471c --- /dev/null +++ b/contracts/tests/source-config-v1/run.ps1 @@ -0,0 +1,33 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$testDirectory = $PSScriptRoot +$requirements = Join-Path $testDirectory 'requirements.txt' +$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$workDirectory = Join-Path $tempRoot ("yovision-source-config-v1-{0}" -f [Guid]::NewGuid().ToString('N')) + +try { + New-Item -ItemType Directory -Path $workDirectory | Out-Null + $virtualEnvironment = Join-Path $workDirectory '.venv' + python -m venv $virtualEnvironment + if ($LASTEXITCODE -ne 0) { throw 'Failed to create the isolated Python environment.' } + + $python = Join-Path $virtualEnvironment 'Scripts\python.exe' + $env:PIP_DISABLE_PIP_VERSION_CHECK = '1' + $env:PYTHONDONTWRITEBYTECODE = '1' + & $python -m pip install --quiet --requirement $requirements + if ($LASTEXITCODE -ne 0) { throw 'Failed to install pinned contract-test dependencies.' } + + & $python -m unittest discover -s $testDirectory -p 'test_*.py' -v + if ($LASTEXITCODE -ne 0) { throw 'Source-config v1 contract tests failed.' } +} +finally { + $resolvedWorkDirectory = [IO.Path]::GetFullPath($workDirectory) + if (-not $resolvedWorkDirectory.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to remove a temporary directory outside $tempRoot" + } + if (Test-Path -LiteralPath $resolvedWorkDirectory) { + Remove-Item -LiteralPath $resolvedWorkDirectory -Recurse -Force + } +} diff --git a/contracts/tests/source-config-v1/test_source_config_v1.py b/contracts/tests/source-config-v1/test_source_config_v1.py new file mode 100644 index 0000000..4adfecd --- /dev/null +++ b/contracts/tests/source-config-v1/test_source_config_v1.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import re +import unittest +from datetime import datetime +from pathlib import Path +from typing import Any + +import rfc8785 +from jsonschema import Draft202012Validator, FormatChecker + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +CONTRACT_ROOT = REPOSITORY_ROOT / "contracts" / "source-config" / "v1" +SCHEMA_PATH = CONTRACT_ROOT / "source-config.schema.json" +VALID_ROOT = CONTRACT_ROOT / "examples" / "valid" +INVALID_ROOT = CONTRACT_ROOT / "examples" / "invalid" +FORBIDDEN_KEY = re.compile(r"(?:credential|password|secret|token|username|cookie|jwt)", re.IGNORECASE) +FORBIDDEN_MEDIA_CHARACTER = re.compile(r"[?@#\\]") + + +def load_json(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + value = json.load(handle) + if not isinstance(value, dict): + raise AssertionError(f"{path} must contain a JSON object") + return value + + +SCHEMA = load_json(SCHEMA_PATH) +VALIDATOR = Draft202012Validator(SCHEMA, format_checker=FormatChecker()) + + +def integrity_value(payload: dict[str, Any]) -> str: + content = copy.deepcopy(payload) + content.pop("integrity", None) + return hashlib.sha256(rfc8785.dumps(content)).hexdigest() + + +def set_integrity(payload: dict[str, Any]) -> None: + payload["integrity"] = {"algorithm": "sha256", "value": integrity_value(payload)} + + +def reject_secrets(value: Any, path: str = "config") -> None: + if isinstance(value, dict): + for key, child in value.items(): + if FORBIDDEN_KEY.search(str(key)): + raise ValueError(f"secret field is forbidden at {path}.{key}") + reject_secrets(child, f"{path}.{key}") + elif isinstance(value, list): + for index, child in enumerate(value): + reject_secrets(child, f"{path}[{index}]") + + +def polygon_area(points: list[dict[str, float]]) -> float: + return abs( + sum( + point["x"] * points[(index + 1) % len(points)]["y"] + - points[(index + 1) % len(points)]["x"] * point["y"] + for index, point in enumerate(points) + ) + / 2 + ) + + +def validate_payload(payload: dict[str, Any]) -> None: + if payload.get("schema") != "yovision.source-config/v1": + raise ValueError("unknown schema major version") + + reject_secrets(payload) + media_ref = str(payload.get("media", {}).get("ref", "")) + if ( + FORBIDDEN_MEDIA_CHARACTER.search(media_ref) + or "://" in media_ref + or re.match(r"^[A-Za-z]:", media_ref) + ): + raise ValueError("secret, query, authority, or internal path in media reference") + + errors = sorted(VALIDATOR.iter_errors(payload), key=lambda error: list(error.absolute_path)) + if errors: + first = errors[0] + location = ".".join(str(part) for part in first.absolute_path) or "config" + raise ValueError(f"schema validation failed at {location}: {first.message}") + + profile = payload["profile"] + binding = payload["ruleSet"]["profileBinding"] + if (binding["profileId"], binding["width"], binding["height"]) != ( + profile["id"], + profile["width"], + profile["height"], + ): + raise ValueError("profile binding does not match the media profile") + + published_at = datetime.fromisoformat(payload["publishedAt"].replace("Z", "+00:00")) + effective_at = datetime.fromisoformat(payload["effectiveAt"].replace("Z", "+00:00")) + if effective_at < published_at: + raise ValueError("effectiveAt precedes publishedAt") + + rule_set = payload["ruleSet"] + rules = [*rule_set["areas"], *rule_set["directionalLines"]] + identifiers = [rule["id"] for rule in rules] + if len(identifiers) != len(set(identifiers)): + raise ValueError("rule ids must be unique across the rule set") + if rule_set["state"] == "recalibration_required" and any(rule["enabled"] for rule in rules): + raise ValueError("recalibration-required rules must not remain enabled") + + for area in rule_set["areas"]: + if polygon_area(area["points"]) <= 1e-12: + raise ValueError(f"area {area['id']} is a degenerate polygon") + for line in rule_set["directionalLines"]: + if line["start"] == line["end"]: + raise ValueError(f"directional line {line['id']} has identical endpoints") + + if payload["integrity"]["value"] != integrity_value(payload): + raise ValueError("integrity digest mismatch") + + +def validate_transition(previous: dict[str, Any], current: dict[str, Any]) -> None: + validate_payload(previous) + validate_payload(current) + if previous["configId"] != current["configId"]: + raise ValueError("configId cannot change within one revision stream") + if current["revision"] <= previous["revision"]: + raise ValueError("revision must increase strictly") + + previous_profile = previous["profile"] + current_profile = current["profile"] + profile_changed = any( + previous_profile[field] != current_profile[field] + for field in ("id", "width", "height", "encoding") + ) + previous_rule_versions = sorted( + (rule["id"], rule["version"]) + for rule in [*previous["ruleSet"]["areas"], *previous["ruleSet"]["directionalLines"]] + ) + current_rule_versions = sorted( + (rule["id"], rule["version"]) + for rule in [*current["ruleSet"]["areas"], *current["ruleSet"]["directionalLines"]] + ) + if ( + profile_changed + and previous_rule_versions == current_rule_versions + and current["ruleSet"]["state"] != "recalibration_required" + ): + raise ValueError("profile changed without rule recalibration state or new rule versions") + + +class SourceConfigV1ContractTests(unittest.TestCase): + def test_schema_is_valid_draft_2020_12(self) -> None: + Draft202012Validator.check_schema(SCHEMA) + + def test_all_valid_examples_pass_schema_semantics_and_integrity(self) -> None: + examples = sorted(VALID_ROOT.glob("*.json")) + self.assertGreaterEqual(len(examples), 2) + for path in examples: + with self.subTest(path=path.name): + validate_payload(load_json(path)) + + def test_invalid_examples_fail_for_the_declared_reason(self) -> None: + expected = load_json(INVALID_ROOT / "expected-errors.json") + self.assertGreaterEqual(len(expected), 6) + for filename, reason in expected.items(): + with self.subTest(path=filename): + with self.assertRaisesRegex(ValueError, str(reason)): + validate_payload(load_json(INVALID_ROOT / filename)) + + def test_tampering_is_detected_after_other_validation(self) -> None: + payload = load_json(VALID_ROOT / "active.json") + payload["revision"] += 1 + with self.assertRaisesRegex(ValueError, "integrity digest mismatch"): + validate_payload(payload) + + def test_namespaced_optional_extensions_are_compatible_but_not_secret_bearing(self) -> None: + payload = load_json(VALID_ROOT / "active.json") + payload["extensions"] = {"example.analytics": {"samplingHint": "balanced"}} + set_integrity(payload) + validate_payload(payload) + + payload["extensions"] = {"example.analytics": {"accessToken": "forbidden"}} + set_integrity(payload) + with self.assertRaisesRegex(ValueError, "secret field"): + validate_payload(payload) + + def test_profile_revision_and_recalibration_semantics_are_safe(self) -> None: + payload = load_json(VALID_ROOT / "active.json") + payload["ruleSet"]["profileBinding"]["width"] = 1280 + set_integrity(payload) + with self.assertRaisesRegex(ValueError, "profile binding"): + validate_payload(payload) + + payload = load_json(VALID_ROOT / "active.json") + payload["ruleSet"]["state"] = "recalibration_required" + set_integrity(payload) + with self.assertRaisesRegex(ValueError, "must not remain enabled"): + validate_payload(payload) + + def test_revision_stream_rejects_stale_and_unrecalibrated_profile_change(self) -> None: + previous = load_json(VALID_ROOT / "active.json") + current = copy.deepcopy(previous) + current["revision"] = previous["revision"] + set_integrity(current) + with self.assertRaisesRegex(ValueError, "revision must increase"): + validate_transition(previous, current) + + current["revision"] += 1 + current["profile"].update({"id": "main-stream-v2", "width": 1280, "height": 720}) + current["ruleSet"]["profileBinding"].update( + {"profileId": "main-stream-v2", "width": 1280, "height": 720} + ) + set_integrity(current) + with self.assertRaisesRegex(ValueError, "without rule recalibration"): + validate_transition(previous, current) + + validate_transition(previous, load_json(VALID_ROOT / "recalibration-required.json")) + + def test_rule_geometry_and_global_ids_are_semantically_validated(self) -> None: + payload = load_json(VALID_ROOT / "active.json") + payload["ruleSet"]["areas"][0]["points"] = [ + {"x": 0, "y": 0}, + {"x": 0.5, "y": 0.5}, + {"x": 1, "y": 1}, + ] + set_integrity(payload) + with self.assertRaisesRegex(ValueError, "degenerate polygon"): + validate_payload(payload) + + payload = load_json(VALID_ROOT / "active.json") + payload["ruleSet"]["directionalLines"][0]["id"] = payload["ruleSet"]["areas"][0]["id"] + set_integrity(payload) + with self.assertRaisesRegex(ValueError, "ids must be unique"): + validate_payload(payload) + + def test_effective_time_cannot_precede_publication(self) -> None: + payload = load_json(VALID_ROOT / "active.json") + payload["effectiveAt"] = "2026-08-30T23:59:59Z" + set_integrity(payload) + with self.assertRaisesRegex(ValueError, "precedes"): + validate_payload(payload) + + def test_shared_payload_does_not_claim_either_product_internal_model(self) -> None: + for path in sorted(VALID_ROOT.glob("*.json")): + serialized = json.dumps(load_json(path), ensure_ascii=False).lower() + self.assertNotIn("brain.internal.input", serialized) + self.assertNotIn("streamuri", serialized) + self.assertNotIn("profiletoken", serialized) + self.assertNotIn("database", serialized) + self.assertNotRegex(serialized, r"[a-z]:\\") + + def test_mapper_documents_both_product_test_responsibilities(self) -> None: + mapper = (CONTRACT_ROOT / "mapper-fields.md").read_text(encoding="utf-8") + self.assertIn("Sense 生产者契约测试", mapper) + self.assertIn("Brain 消费者契约测试", mapper) + self.assertIn("brain.internal.input/v1", mapper) + self.assertIn("admissionProfile.StreamURI", mapper) + + +if __name__ == "__main__": + unittest.main() -- 2.34.1 From 2e61167500459666f5d2339a0d06a12c335a24b1 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 31 Aug 2026 08:53:22 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E6=BA=90=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=A5=91=E7=BA=A6=E5=AD=97=E6=AE=B5=E5=91=BD=E5=90=8D?= =?UTF-8?q?=20(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/source-config/v1/README.md | 10 +- contracts/source-config/v1/compatibility.md | 10 +- .../invalid/coordinate-out-of-range.json | 14 +-- .../v1/examples/invalid/credential-field.json | 14 +-- .../v1/examples/invalid/internal-path.json | 14 +-- .../invalid/profile-binding-mismatch.json | 14 +-- .../v1/examples/invalid/query-token.json | 14 +-- .../invalid/unknown-major-version.json | 14 +-- .../v1/examples/valid/active.json | 24 ++--- .../valid/recalibration-required.json | 22 ++--- contracts/source-config/v1/mapper-fields.md | 32 +++---- .../v1/source-config.schema.json | 92 +++++++++---------- .../source-config-v1/test_source_config_v1.py | 42 ++++----- 13 files changed, 158 insertions(+), 158 deletions(-) diff --git a/contracts/source-config/v1/README.md b/contracts/source-config/v1/README.md index b40c82e..77e6cb4 100644 --- a/contracts/source-config/v1/README.md +++ b/contracts/source-config/v1/README.md @@ -15,11 +15,11 @@ ## 消费规则 1. 先按 JSON Schema 校验,再执行跨字段语义校验。 -2. `schema` 必须精确等于 `yovision.source-config/v1`;未知主版本不得降级猜测。 -3. `ruleSet.profileBinding` 必须与 `profile.id/width/height` 完全一致。 -4. `ruleSet.state != active` 时不得运行任何规则;`recalibration_required` 表示 Profile 规格变化后需重新标定。 -5. `areas` 与 `directionalLines` 的 `id` 在同一快照内必须全局唯一;多边形必须非退化,线段起终点不得相同。 -6. `effectiveAt` 不得早于 `publishedAt`。 +2. `schema_version` 必须精确等于 `yovision.source-config/v1`;未知主版本不得降级猜测。 +3. `rule_set.profile_binding` 必须与 `profile.id/width/height` 完全一致。 +4. `rule_set.state != active` 时不得运行任何规则;`recalibration_required` 表示 Profile 规格变化后需重新标定。 +5. `areas` 与 `directional_lines` 的 `id` 在同一快照内必须全局唯一;多边形必须非退化,线段起终点不得相同。 +6. `effective_at` 不得早于 `published_at`。 7. `integrity.value` 是移除顶层 `integrity` 后,对 RFC 8785 JCS 规范化 JSON 字节计算的 SHA-256 小写十六进制摘要。生产消费者应使用合规 JCS 实现;仓库样例只使用 JCS 简单类型子集。 `media.ref` 是 connector 解析的无凭据不透明引用,固定以 `media:` 开头。它不能包含 URI authority、用户名、密码、查询参数、fragment、Windows 盘符或文件系统路径。RTSP 凭据交换与机器身份不属于本契约。 diff --git a/contracts/source-config/v1/compatibility.md b/contracts/source-config/v1/compatibility.md index f7546c7..f434f14 100644 --- a/contracts/source-config/v1/compatibility.md +++ b/contracts/source-config/v1/compatibility.md @@ -3,15 +3,15 @@ ## 兼容规则 - v1 发布后只允许在预留的顶层 `extensions` 对象中增加命名空间化、非秘密的可选扩展。消费者必须忽略自己不认识的扩展命名空间,但仍须拒绝当前 Schema 或语义规则标记为非法的输入;发布扩展时应同步生产者/消费者测试。v1 核心对象保持封闭,不能通过新增核心字段规避新主版本。 -- 删除字段、把可选改为必填、收紧已发布取值范围,或改变字段类型、单位、坐标系、Profile 绑定、revision、状态及媒体引用语义,均为破坏性变化,必须发布新主版本目录和新的 `schema` 值。 +- 删除字段、把可选改为必填、收紧已发布取值范围,或改变字段类型、单位、坐标系、Profile 绑定、revision、状态及媒体引用语义,均为破坏性变化,必须发布新主版本目录和新的 `schema_version` 值。 - 未知主版本必须安全拒绝并保留最后一个已验证配置。不得把未知版本转换成 v1,也不得继续启用来自未知版本的规则。 - v1 的坐标始终是相对于 `profile.width × profile.height` 图像平面的 0–1 归一化坐标;原点在左上,x 向右、y 向下。该语义不得在 v1 内改变。 ## revision 与生效 -- `(configId, revision)` 唯一标识一个不可变快照;同一 `configId` 的新发布必须使用严格递增的 `revision`。 -- 消费者仅在 Schema、语义和完整性均通过后,按 `effectiveAt` 原子切换整个快照。重复收到同一 revision 应幂等处理;更小 revision 应拒绝为陈旧配置。 -- Profile ID、分辨率或编码变化时,生产者必须发布新 revision。已有几何尚未按新 Profile 校准时,必须设置 `ruleSet.state = recalibration_required`;消费者不得启用其中规则。 +- `(config_id, revision)` 唯一标识一个不可变快照;同一 `config_id` 的新发布必须使用严格递增的 `revision`。 +- 消费者仅在 Schema、语义和完整性均通过后,按 `effective_at` 原子切换整个快照。重复收到同一 revision 应幂等处理;更小 revision 应拒绝为陈旧配置。 +- Profile ID、分辨率或编码变化时,生产者必须发布新 revision。已有几何尚未按新 Profile 校准时,必须设置 `rule_set.state = recalibration_required`;消费者不得启用其中规则。 - 新 revision 校验失败或未到生效时间时,消费者保留上一份已验证且仍有效的 active revision。 ## 支持周期 @@ -22,7 +22,7 @@ ## 回退 1. 停止分发有问题的新主版本或新 revision。 -2. 重新发布上一主版本的最后一个已验证快照;若仍为同一 `configId`,必须使用该主版本下新的、更大 revision,不能覆盖历史 revision。 +2. 重新发布上一主版本的最后一个已验证快照;若仍为同一 `config_id`,必须使用该主版本下新的、更大 revision,不能覆盖历史 revision。 3. Brain 通过完整 Schema、语义和摘要校验后原子切回;切换前继续使用最后一个有效快照,或在没有有效快照时保持规则停用。 4. 记录失败版本和拒绝原因,但不得记录媒体凭据或完整客户配置。 diff --git a/contracts/source-config/v1/examples/invalid/coordinate-out-of-range.json b/contracts/source-config/v1/examples/invalid/coordinate-out-of-range.json index dce977f..108bf7b 100644 --- a/contracts/source-config/v1/examples/invalid/coordinate-out-of-range.json +++ b/contracts/source-config/v1/examples/invalid/coordinate-out-of-range.json @@ -1,13 +1,13 @@ { - "schema": "yovision.source-config/v1", - "configId": "school-east-entry-01", + "schema_version": "yovision.source-config/v1", + "config_id": "school-east-entry-01", "revision": 7, - "publishedAt": "2026-08-31T00:10:00Z", - "effectiveAt": "2026-08-31T00:15:00Z", + "published_at": "2026-08-31T00:10:00Z", + "effective_at": "2026-08-31T00:15:00Z", "site": {"id": "site-east"}, - "logicalDevice": {"id": "entry-camera-01"}, - "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "logical_device": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25}, "media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"}, - "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [{"id": "bad-area", "version": 1, "kind": "danger_area", "enabled": true, "points": [{"x": 0, "y": 0}, {"x": 1.2, "y": 0}, {"x": 0, "y": 1}]}], "directionalLines": []}, + "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [{"id": "bad-area", "version": 1, "kind": "danger_area", "enabled": true, "points": [{"x": 0, "y": 0}, {"x": 1.2, "y": 0}, {"x": 0, "y": 1}]}], "directional_lines": []}, "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} } diff --git a/contracts/source-config/v1/examples/invalid/credential-field.json b/contracts/source-config/v1/examples/invalid/credential-field.json index e58181a..ac0ce55 100644 --- a/contracts/source-config/v1/examples/invalid/credential-field.json +++ b/contracts/source-config/v1/examples/invalid/credential-field.json @@ -1,13 +1,13 @@ { - "schema": "yovision.source-config/v1", - "configId": "school-east-entry-01", + "schema_version": "yovision.source-config/v1", + "config_id": "school-east-entry-01", "revision": 7, - "publishedAt": "2026-08-31T00:10:00Z", - "effectiveAt": "2026-08-31T00:15:00Z", + "published_at": "2026-08-31T00:10:00Z", + "effective_at": "2026-08-31T00:15:00Z", "site": {"id": "site-east"}, - "logicalDevice": {"id": "entry-camera-01"}, - "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "logical_device": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25}, "media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp", "password": null}, - "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directionalLines": []}, + "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []}, "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} } diff --git a/contracts/source-config/v1/examples/invalid/internal-path.json b/contracts/source-config/v1/examples/invalid/internal-path.json index 80adf98..5391989 100644 --- a/contracts/source-config/v1/examples/invalid/internal-path.json +++ b/contracts/source-config/v1/examples/invalid/internal-path.json @@ -1,13 +1,13 @@ { - "schema": "yovision.source-config/v1", - "configId": "school-east-entry-01", + "schema_version": "yovision.source-config/v1", + "config_id": "school-east-entry-01", "revision": 7, - "publishedAt": "2026-08-31T00:10:00Z", - "effectiveAt": "2026-08-31T00:15:00Z", + "published_at": "2026-08-31T00:10:00Z", + "effective_at": "2026-08-31T00:15:00Z", "site": {"id": "site-east"}, - "logicalDevice": {"id": "entry-camera-01"}, - "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "logical_device": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25}, "media": {"ref": "C:\\customers\\school-east\\camera-01", "transport": "rtsp"}, - "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directionalLines": []}, + "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []}, "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} } diff --git a/contracts/source-config/v1/examples/invalid/profile-binding-mismatch.json b/contracts/source-config/v1/examples/invalid/profile-binding-mismatch.json index 2189566..6b24511 100644 --- a/contracts/source-config/v1/examples/invalid/profile-binding-mismatch.json +++ b/contracts/source-config/v1/examples/invalid/profile-binding-mismatch.json @@ -1,13 +1,13 @@ { - "schema": "yovision.source-config/v1", - "configId": "school-east-entry-01", + "schema_version": "yovision.source-config/v1", + "config_id": "school-east-entry-01", "revision": 7, - "publishedAt": "2026-08-31T00:10:00Z", - "effectiveAt": "2026-08-31T00:15:00Z", + "published_at": "2026-08-31T00:10:00Z", + "effective_at": "2026-08-31T00:15:00Z", "site": {"id": "site-east"}, - "logicalDevice": {"id": "entry-camera-01"}, - "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "logical_device": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25}, "media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"}, - "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1280, "height": 720}, "areas": [], "directionalLines": []}, + "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1280, "height": 720}, "areas": [], "directional_lines": []}, "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} } diff --git a/contracts/source-config/v1/examples/invalid/query-token.json b/contracts/source-config/v1/examples/invalid/query-token.json index f8e204c..bf1ce64 100644 --- a/contracts/source-config/v1/examples/invalid/query-token.json +++ b/contracts/source-config/v1/examples/invalid/query-token.json @@ -1,13 +1,13 @@ { - "schema": "yovision.source-config/v1", - "configId": "school-east-entry-01", + "schema_version": "yovision.source-config/v1", + "config_id": "school-east-entry-01", "revision": 7, - "publishedAt": "2026-08-31T00:10:00Z", - "effectiveAt": "2026-08-31T00:15:00Z", + "published_at": "2026-08-31T00:10:00Z", + "effective_at": "2026-08-31T00:15:00Z", "site": {"id": "site-east"}, - "logicalDevice": {"id": "entry-camera-01"}, - "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "logical_device": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25}, "media": {"ref": "media:site-east/entry-01/main?token=", "transport": "rtsp"}, - "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directionalLines": []}, + "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []}, "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} } diff --git a/contracts/source-config/v1/examples/invalid/unknown-major-version.json b/contracts/source-config/v1/examples/invalid/unknown-major-version.json index 9b231fb..a8a2ddb 100644 --- a/contracts/source-config/v1/examples/invalid/unknown-major-version.json +++ b/contracts/source-config/v1/examples/invalid/unknown-major-version.json @@ -1,13 +1,13 @@ { - "schema": "yovision.source-config/v2", - "configId": "school-east-entry-01", + "schema_version": "yovision.source-config/v2", + "config_id": "school-east-entry-01", "revision": 7, - "publishedAt": "2026-08-31T00:10:00Z", - "effectiveAt": "2026-08-31T00:15:00Z", + "published_at": "2026-08-31T00:10:00Z", + "effective_at": "2026-08-31T00:15:00Z", "site": {"id": "site-east"}, - "logicalDevice": {"id": "entry-camera-01"}, - "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "logical_device": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25}, "media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"}, - "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directionalLines": []}, + "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []}, "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} } diff --git a/contracts/source-config/v1/examples/valid/active.json b/contracts/source-config/v1/examples/valid/active.json index 0f2f408..deb2bc7 100644 --- a/contracts/source-config/v1/examples/valid/active.json +++ b/contracts/source-config/v1/examples/valid/active.json @@ -1,13 +1,13 @@ { - "schema": "yovision.source-config/v1", - "configId": "school-east-entry-01", + "schema_version": "yovision.source-config/v1", + "config_id": "school-east-entry-01", "revision": 7, - "publishedAt": "2026-08-31T00:10:00Z", - "effectiveAt": "2026-08-31T00:15:00Z", + "published_at": "2026-08-31T00:10:00Z", + "effective_at": "2026-08-31T00:15:00Z", "site": { "id": "site-east" }, - "logicalDevice": { + "logical_device": { "id": "entry-camera-01" }, "profile": { @@ -15,17 +15,17 @@ "width": 1920, "height": 1080, "encoding": "H264", - "frameRate": 25 + "frame_rate": 25 }, "media": { "ref": "media:site-east/entry-01/main", "transport": "rtsp" }, - "ruleSet": { + "rule_set": { "version": "entry-rules-7", "state": "active", - "profileBinding": { - "profileId": "main-stream", + "profile_binding": { + "profile_id": "main-stream", "width": 1920, "height": 1080 }, @@ -43,7 +43,7 @@ ] } ], - "directionalLines": [ + "directional_lines": [ { "id": "entry-line", "version": 2, @@ -51,12 +51,12 @@ "enabled": true, "start": {"x": 0.2, "y": 0.5}, "end": {"x": 0.8, "y": 0.5}, - "triggerDirection": "left_to_right" + "trigger_direction": "left_to_right" } ] }, "integrity": { "algorithm": "sha256", - "value": "b35a4f345162b059d6912cca6340a6714826b7921662f4f28a91ef29c5476cb8" + "value": "3336fe595bf1401b1024ac0c95c31e1655228485465a4527900fcea2c713acfe" } } diff --git a/contracts/source-config/v1/examples/valid/recalibration-required.json b/contracts/source-config/v1/examples/valid/recalibration-required.json index 691b661..1c67fbc 100644 --- a/contracts/source-config/v1/examples/valid/recalibration-required.json +++ b/contracts/source-config/v1/examples/valid/recalibration-required.json @@ -1,13 +1,13 @@ { - "schema": "yovision.source-config/v1", - "configId": "school-east-entry-01", + "schema_version": "yovision.source-config/v1", + "config_id": "school-east-entry-01", "revision": 8, - "publishedAt": "2026-08-31T01:00:00Z", - "effectiveAt": "2026-08-31T01:00:00Z", + "published_at": "2026-08-31T01:00:00Z", + "effective_at": "2026-08-31T01:00:00Z", "site": { "id": "site-east" }, - "logicalDevice": { + "logical_device": { "id": "entry-camera-01" }, "profile": { @@ -15,25 +15,25 @@ "width": 1280, "height": 720, "encoding": "H265", - "frameRate": 20 + "frame_rate": 20 }, "media": { "ref": "media:site-east/entry-01/main-v2", "transport": "rtsp" }, - "ruleSet": { + "rule_set": { "version": "entry-rules-8", "state": "recalibration_required", - "profileBinding": { - "profileId": "main-stream-v2", + "profile_binding": { + "profile_id": "main-stream-v2", "width": 1280, "height": 720 }, "areas": [], - "directionalLines": [] + "directional_lines": [] }, "integrity": { "algorithm": "sha256", - "value": "04408f00740558c70d9d1e3f9f5efbf03e6f22ccc0ba4ec6b6c8688df7b568df" + "value": "a53e6df8bab5c9a4e3f2dae2e82959939db09d34529af9ae65d66f322be833ba" } } diff --git a/contracts/source-config/v1/mapper-fields.md b/contracts/source-config/v1/mapper-fields.md index 73cdfea..0a985a3 100644 --- a/contracts/source-config/v1/mapper-fields.md +++ b/contracts/source-config/v1/mapper-fields.md @@ -4,32 +4,32 @@ mapper 必须创建新的契约 DTO,不得直接序列化 Sense GORM 实体, | 契约字段 | Sense 生产来源/规则 | Brain 消费目标/规则 | |---|---|---| -| `schema` | 常量 `yovision.source-config/v1` | 在任何映射前精确校验;未知主版本拒绝 | -| `configId` | 新的稳定配置聚合 ID;不是数据库行 ID 语义 | 作为配置流逻辑 ID,不解释为 Brain 内部对象 ID | -| `revision` | 聚合配置变更时严格递增;不可复用 | 与 `configId` 共同做幂等、顺序和陈旧检查 | -| `publishedAt` / `effectiveAt` | 发布时写 UTC RFC 3339;生效不得早于发布 | 完整校验后按生效时间原子切换 | +| `schema_version` | 常量 `yovision.source-config/v1` | 在任何映射前精确校验;未知主版本拒绝 | +| `config_id` | 新的稳定配置聚合 ID;不是数据库行 ID 语义 | 作为配置流逻辑 ID,不解释为 Brain 内部对象 ID | +| `revision` | 聚合配置变更时严格递增;不可复用 | 与 `config_id` 共同做幂等、顺序和陈旧检查 | +| `published_at` / `effective_at` | 发布时写 UTC RFC 3339;生效不得早于发布 | 完整校验后按生效时间原子切换 | | `site.id` | 对外稳定站点引用;不得映射客户名或数据库主键语义 | 仅作租户隔离后的逻辑关联;v1 不提供用户身份 | -| `logicalDevice.id` | `area.Definition.DeviceID` / `media.Route.DeviceID` 经稳定外部 ID mapper | 映射到 `BrainInputConfig.logical_device_id` | +| `logical_device.id` | `area.Definition.DeviceID` / `media.Route.DeviceID` 经稳定外部 ID mapper | 映射到 `BrainInputConfig.logical_device_id` | | `profile.id` | `area.Definition.ProfileToken` 与 `media.Route.ProfileToken` 经稳定 Profile ID mapper | 映射到 `BrainInputConfig.profile.profile_id` | | `profile.width/height/encoding` | `area.Definition.ProfileWidth/ProfileHeight/ProfileEncoding`;必须与当前媒体 Profile 一致 | 映射到规则 `RuleSet` 的 Profile 绑定;不一致拒绝 | -| `profile.frameRate` | Sense 已验证 Profile 的帧率快照 | 映射到 `BrainInputConfig.profile.fps` | +| `profile.frame_rate` | Sense 已验证 Profile 的帧率快照 | 映射到 `BrainInputConfig.profile.fps` | | `media.ref` | 由 `media.Route.ID/Path` 生成 `media:`;禁止读取或拼入 `admissionProfile.StreamURI` 及凭据 | 交给后续 connector 解析;不得当作 RTSP URL 或本地路径 | | `media.transport` | 当前固定 `rtsp`,仅描述媒体传输类别 | 选择后续 connector/decode adapter;不含认证信息 | -| `ruleSet.version` | 由一组 `area.Version` 聚合成稳定规则集版本 | 映射到 Brain `RuleSet.version` | -| `ruleSet.state` | `NeedsRecalibration=true` → `recalibration_required`;整体禁用 → `disabled`;否则 `active` | 只有 `active` 可构建并启用规则引擎 | -| `ruleSet.profileBinding` | 与本快照 `profile.id/width/height` 同源复制并交叉校验 | 必须精确等于 `profile`;之后才接受归一化几何 | -| `ruleSet.areas[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `AreaRule.rule_id`;version 用于可追溯性 | -| `ruleSet.areas[].kind` | Sense `polygon` 映射为 `danger_area` | 只映射到 Brain 危险区域规则,不透传 Sense 枚举 | -| `ruleSet.areas[].points` | `area.Version.GeometryJSON` 中 `{x,y}`;保持 0–1 | 映射到 Brain `Point(x,y)`;至少三点且非退化 | -| `ruleSet.directionalLines[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `DirectionalLineRule.rule_id` | -| `ruleSet.directionalLines[].start/end` | `direction_line` 几何的两个归一化点 | 映射到 Brain `Point`;相同点拒绝 | -| `ruleSet.directionalLines[].triggerDirection` | Sense `forward/reverse` 必须由 mapper 根据已确认的起终点方向转换为 `left_to_right/right_to_left` | 映射到 `DirectionalLineRule.trigger_direction`;不得直接猜测枚举 | +| `rule_set.version` | 由一组 `area.Version` 聚合成稳定规则集版本 | 映射到 Brain `RuleSet.version` | +| `rule_set.state` | `NeedsRecalibration=true` → `recalibration_required`;整体禁用 → `disabled`;否则 `active` | 只有 `active` 可构建并启用规则引擎 | +| `rule_set.profile_binding` | 与本快照 `profile.id/width/height` 同源复制并交叉校验 | 必须精确等于 `profile`;之后才接受归一化几何 | +| `rule_set.areas[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `AreaRule.rule_id`;version 用于可追溯性 | +| `rule_set.areas[].kind` | Sense `polygon` 映射为 `danger_area` | 只映射到 Brain 危险区域规则,不透传 Sense 枚举 | +| `rule_set.areas[].points` | `area.Version.GeometryJSON` 中 `{x,y}`;保持 0–1 | 映射到 Brain `Point(x,y)`;至少三点且非退化 | +| `rule_set.directional_lines[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `DirectionalLineRule.rule_id` | +| `rule_set.directional_lines[].start/end` | `direction_line` 几何的两个归一化点 | 映射到 Brain `Point`;相同点拒绝 | +| `rule_set.directional_lines[].trigger_direction` | Sense `forward/reverse` 必须由 mapper 根据已确认的起终点方向转换为 `left_to_right/right_to_left` | 映射到 `DirectionalLineRule.trigger_direction`;不得直接猜测枚举 | | `integrity` | 对移除 `integrity` 的 JCS 快照计算 SHA-256 | 映射前重算并常量时间比较;失败保留上一有效 revision | ## 测试责任 - Sense 生产者契约测试:从设备、媒体 Route、Profile 与区域版本 fixture 生成快照;断言字段映射、revision 递增、Profile 变化触发新 revision/待重校准、无秘密媒体引用、Schema/语义/摘要通过。 -- Brain 消费者契约测试:加载本目录有效与无效样例;断言版本拒绝、幂等/陈旧处理、Profile 绑定、坐标、规则 ID、状态门禁和摘要;再映射为 Brain 内部配置,证明共享 `schema` 不等于 `brain.internal.input/v1`。 +- Brain 消费者契约测试:加载本目录有效与无效样例;断言版本拒绝、幂等/陈旧处理、Profile 绑定、坐标、规则 ID、状态门禁和摘要;再映射为 Brain 内部配置,证明共享 `schema_version` 不等于 `brain.internal.input/v1`。 - 协调契约测试(本工单):校验所有样例、秘密字段/URL/本地路径拒绝、跨字段语义和摘要。产品 adapter 测试在后续 connector 工单实施。 Sense 与 Brain 各自可增加内部字段,但不得将数据库主键、用户表、JWT、Cookie、摄像头凭据、客户内部路径或内部模型直接扩展进本契约。 diff --git a/contracts/source-config/v1/source-config.schema.json b/contracts/source-config/v1/source-config.schema.json index cb1e2fe..e28a060 100644 --- a/contracts/source-config/v1/source-config.schema.json +++ b/contracts/source-config/v1/source-config.schema.json @@ -6,34 +6,34 @@ "type": "object", "additionalProperties": false, "required": [ - "schema", - "configId", + "schema_version", + "config_id", "revision", - "publishedAt", - "effectiveAt", + "published_at", + "effective_at", "site", - "logicalDevice", + "logical_device", "profile", "media", - "ruleSet", + "rule_set", "integrity" ], "properties": { - "schema": { + "schema_version": { "const": "yovision.source-config/v1" }, - "configId": { - "$ref": "#/$defs/stableId" + "config_id": { + "$ref": "#/$defs/stable_id" }, "revision": { "type": "integer", "minimum": 1 }, - "publishedAt": { + "published_at": { "type": "string", "format": "date-time" }, - "effectiveAt": { + "effective_at": { "type": "string", "format": "date-time" }, @@ -43,17 +43,17 @@ "required": ["id"], "properties": { "id": { - "$ref": "#/$defs/stableId" + "$ref": "#/$defs/stable_id" } } }, - "logicalDevice": { + "logical_device": { "type": "object", "additionalProperties": false, "required": ["id"], "properties": { "id": { - "$ref": "#/$defs/stableId" + "$ref": "#/$defs/stable_id" } } }, @@ -75,37 +75,37 @@ } } }, - "ruleSet": { + "rule_set": { "type": "object", "additionalProperties": false, "required": [ "version", "state", - "profileBinding", + "profile_binding", "areas", - "directionalLines" + "directional_lines" ], "properties": { "version": { - "$ref": "#/$defs/stableId" + "$ref": "#/$defs/stable_id" }, "state": { "enum": ["active", "disabled", "recalibration_required"] }, - "profileBinding": { - "$ref": "#/$defs/profileBinding" + "profile_binding": { + "$ref": "#/$defs/profile_binding" }, "areas": { "type": "array", "items": { - "$ref": "#/$defs/areaRule" + "$ref": "#/$defs/area_rule" }, "maxItems": 1024 }, - "directionalLines": { + "directional_lines": { "type": "array", "items": { - "$ref": "#/$defs/directionalLineRule" + "$ref": "#/$defs/directional_line_rule" }, "maxItems": 1024 } @@ -137,55 +137,55 @@ } }, "$defs": { - "stableId": { + "stable_id": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$" }, - "positiveInteger": { + "positive_integer": { "type": "integer", "minimum": 1 }, - "positiveNumber": { + "positive_number": { "type": "number", "exclusiveMinimum": 0 }, "profile": { "type": "object", "additionalProperties": false, - "required": ["id", "width", "height", "encoding", "frameRate"], + "required": ["id", "width", "height", "encoding", "frame_rate"], "properties": { "id": { - "$ref": "#/$defs/stableId" + "$ref": "#/$defs/stable_id" }, "width": { - "$ref": "#/$defs/positiveInteger" + "$ref": "#/$defs/positive_integer" }, "height": { - "$ref": "#/$defs/positiveInteger" + "$ref": "#/$defs/positive_integer" }, "encoding": { "enum": ["H264", "H265", "MJPEG"] }, - "frameRate": { - "$ref": "#/$defs/positiveNumber" + "frame_rate": { + "$ref": "#/$defs/positive_number" } } }, - "profileBinding": { + "profile_binding": { "type": "object", "additionalProperties": false, - "required": ["profileId", "width", "height"], + "required": ["profile_id", "width", "height"], "properties": { - "profileId": { - "$ref": "#/$defs/stableId" + "profile_id": { + "$ref": "#/$defs/stable_id" }, "width": { - "$ref": "#/$defs/positiveInteger" + "$ref": "#/$defs/positive_integer" }, "height": { - "$ref": "#/$defs/positiveInteger" + "$ref": "#/$defs/positive_integer" } } }, @@ -206,16 +206,16 @@ } } }, - "areaRule": { + "area_rule": { "type": "object", "additionalProperties": false, "required": ["id", "version", "kind", "enabled", "points"], "properties": { "id": { - "$ref": "#/$defs/stableId" + "$ref": "#/$defs/stable_id" }, "version": { - "$ref": "#/$defs/positiveInteger" + "$ref": "#/$defs/positive_integer" }, "kind": { "const": "danger_area" @@ -233,7 +233,7 @@ } } }, - "directionalLineRule": { + "directional_line_rule": { "type": "object", "additionalProperties": false, "required": [ @@ -243,14 +243,14 @@ "enabled", "start", "end", - "triggerDirection" + "trigger_direction" ], "properties": { "id": { - "$ref": "#/$defs/stableId" + "$ref": "#/$defs/stable_id" }, "version": { - "$ref": "#/$defs/positiveInteger" + "$ref": "#/$defs/positive_integer" }, "kind": { "const": "directional_line" @@ -264,7 +264,7 @@ "end": { "$ref": "#/$defs/point" }, - "triggerDirection": { + "trigger_direction": { "enum": ["left_to_right", "right_to_left"] } } diff --git a/contracts/tests/source-config-v1/test_source_config_v1.py b/contracts/tests/source-config-v1/test_source_config_v1.py index 4adfecd..cbc8a15 100644 --- a/contracts/tests/source-config-v1/test_source_config_v1.py +++ b/contracts/tests/source-config-v1/test_source_config_v1.py @@ -67,7 +67,7 @@ def polygon_area(points: list[dict[str, float]]) -> float: def validate_payload(payload: dict[str, Any]) -> None: - if payload.get("schema") != "yovision.source-config/v1": + if payload.get("schema_version") != "yovision.source-config/v1": raise ValueError("unknown schema major version") reject_secrets(payload) @@ -86,21 +86,21 @@ def validate_payload(payload: dict[str, Any]) -> None: raise ValueError(f"schema validation failed at {location}: {first.message}") profile = payload["profile"] - binding = payload["ruleSet"]["profileBinding"] - if (binding["profileId"], binding["width"], binding["height"]) != ( + binding = payload["rule_set"]["profile_binding"] + if (binding["profile_id"], binding["width"], binding["height"]) != ( profile["id"], profile["width"], profile["height"], ): raise ValueError("profile binding does not match the media profile") - published_at = datetime.fromisoformat(payload["publishedAt"].replace("Z", "+00:00")) - effective_at = datetime.fromisoformat(payload["effectiveAt"].replace("Z", "+00:00")) + published_at = datetime.fromisoformat(payload["published_at"].replace("Z", "+00:00")) + effective_at = datetime.fromisoformat(payload["effective_at"].replace("Z", "+00:00")) if effective_at < published_at: - raise ValueError("effectiveAt precedes publishedAt") + raise ValueError("effective_at precedes published_at") - rule_set = payload["ruleSet"] - rules = [*rule_set["areas"], *rule_set["directionalLines"]] + rule_set = payload["rule_set"] + rules = [*rule_set["areas"], *rule_set["directional_lines"]] identifiers = [rule["id"] for rule in rules] if len(identifiers) != len(set(identifiers)): raise ValueError("rule ids must be unique across the rule set") @@ -110,7 +110,7 @@ def validate_payload(payload: dict[str, Any]) -> None: for area in rule_set["areas"]: if polygon_area(area["points"]) <= 1e-12: raise ValueError(f"area {area['id']} is a degenerate polygon") - for line in rule_set["directionalLines"]: + for line in rule_set["directional_lines"]: if line["start"] == line["end"]: raise ValueError(f"directional line {line['id']} has identical endpoints") @@ -121,8 +121,8 @@ def validate_payload(payload: dict[str, Any]) -> None: def validate_transition(previous: dict[str, Any], current: dict[str, Any]) -> None: validate_payload(previous) validate_payload(current) - if previous["configId"] != current["configId"]: - raise ValueError("configId cannot change within one revision stream") + if previous["config_id"] != current["config_id"]: + raise ValueError("config_id cannot change within one revision stream") if current["revision"] <= previous["revision"]: raise ValueError("revision must increase strictly") @@ -134,16 +134,16 @@ def validate_transition(previous: dict[str, Any], current: dict[str, Any]) -> No ) previous_rule_versions = sorted( (rule["id"], rule["version"]) - for rule in [*previous["ruleSet"]["areas"], *previous["ruleSet"]["directionalLines"]] + for rule in [*previous["rule_set"]["areas"], *previous["rule_set"]["directional_lines"]] ) current_rule_versions = sorted( (rule["id"], rule["version"]) - for rule in [*current["ruleSet"]["areas"], *current["ruleSet"]["directionalLines"]] + for rule in [*current["rule_set"]["areas"], *current["rule_set"]["directional_lines"]] ) if ( profile_changed and previous_rule_versions == current_rule_versions - and current["ruleSet"]["state"] != "recalibration_required" + and current["rule_set"]["state"] != "recalibration_required" ): raise ValueError("profile changed without rule recalibration state or new rule versions") @@ -186,13 +186,13 @@ class SourceConfigV1ContractTests(unittest.TestCase): def test_profile_revision_and_recalibration_semantics_are_safe(self) -> None: payload = load_json(VALID_ROOT / "active.json") - payload["ruleSet"]["profileBinding"]["width"] = 1280 + payload["rule_set"]["profile_binding"]["width"] = 1280 set_integrity(payload) with self.assertRaisesRegex(ValueError, "profile binding"): validate_payload(payload) payload = load_json(VALID_ROOT / "active.json") - payload["ruleSet"]["state"] = "recalibration_required" + payload["rule_set"]["state"] = "recalibration_required" set_integrity(payload) with self.assertRaisesRegex(ValueError, "must not remain enabled"): validate_payload(payload) @@ -207,8 +207,8 @@ class SourceConfigV1ContractTests(unittest.TestCase): current["revision"] += 1 current["profile"].update({"id": "main-stream-v2", "width": 1280, "height": 720}) - current["ruleSet"]["profileBinding"].update( - {"profileId": "main-stream-v2", "width": 1280, "height": 720} + current["rule_set"]["profile_binding"].update( + {"profile_id": "main-stream-v2", "width": 1280, "height": 720} ) set_integrity(current) with self.assertRaisesRegex(ValueError, "without rule recalibration"): @@ -218,7 +218,7 @@ class SourceConfigV1ContractTests(unittest.TestCase): def test_rule_geometry_and_global_ids_are_semantically_validated(self) -> None: payload = load_json(VALID_ROOT / "active.json") - payload["ruleSet"]["areas"][0]["points"] = [ + payload["rule_set"]["areas"][0]["points"] = [ {"x": 0, "y": 0}, {"x": 0.5, "y": 0.5}, {"x": 1, "y": 1}, @@ -228,14 +228,14 @@ class SourceConfigV1ContractTests(unittest.TestCase): validate_payload(payload) payload = load_json(VALID_ROOT / "active.json") - payload["ruleSet"]["directionalLines"][0]["id"] = payload["ruleSet"]["areas"][0]["id"] + payload["rule_set"]["directional_lines"][0]["id"] = payload["rule_set"]["areas"][0]["id"] set_integrity(payload) with self.assertRaisesRegex(ValueError, "ids must be unique"): validate_payload(payload) def test_effective_time_cannot_precede_publication(self) -> None: payload = load_json(VALID_ROOT / "active.json") - payload["effectiveAt"] = "2026-08-30T23:59:59Z" + payload["effective_at"] = "2026-08-30T23:59:59Z" set_integrity(payload) with self.assertRaisesRegex(ValueError, "precedes"): validate_payload(payload) -- 2.34.1