fix: 统一源配置契约字段命名 (#148)

This commit is contained in:
QiuSW
2026-08-31 08:53:22 +08:00
parent 67391acb16
commit 2e61167500
13 changed files with 158 additions and 158 deletions
+5 -5
View File
@@ -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 凭据交换与机器身份不属于本契约。
+5 -5
View File
@@ -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. 记录失败版本和拒绝原因,但不得记录媒体凭据或完整客户配置。
@@ -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"}
}
@@ -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"}
}
@@ -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"}
}
@@ -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"}
}
@@ -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"}
}
@@ -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"}
}
@@ -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"
}
}
@@ -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"
}
}
+16 -16
View File
@@ -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:<opaque-resource>`;禁止读取或拼入 `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、摄像头凭据、客户内部路径或内部模型直接扩展进本契约。
@@ -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"]
}
}
@@ -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)