feat(v2): add safe runtime configuration
This commit is contained in:
@@ -53,6 +53,7 @@ V2 对应模块的当前落实与 T-304 目标如下:
|
|||||||
| 模块 | V2 目标位置 | 职责 | 不负责 |
|
| 模块 | V2 目标位置 | 职责 | 不负责 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 回放解码器 | `v2/cmd/regression` | 用受控 FFmpeg 顺序读取 BGR 帧,供本机录像回归 | RTSP 重连、UI 绘制 |
|
| 回放解码器 | `v2/cmd/regression` | 用受控 FFmpeg 顺序读取 BGR 帧,供本机录像回归 | RTSP 重连、UI 绘制 |
|
||||||
|
| 运行配置 | `v2/internal/config` | 校验公开 JSON、仅从环境变量解析 RTSP URL、生成非敏感配置版本 | 保存或显示 URL、账号、密码 |
|
||||||
| ONNX Pose | `v2/internal/pose` | 114 letterbox、RGB/CHW、NMS、关键点及坐标还原 | 人员 ID、摔倒结论 |
|
| ONNX Pose | `v2/internal/pose` | 114 letterbox、RGB/CHW、NMS、关键点及坐标还原 | 人员 ID、摔倒结论 |
|
||||||
| 事件引擎 | `v2/internal/fall` | 复现 V1 的跟踪、证据、四态状态机和 `FallEvent` | 视频解码、声音、文件 |
|
| 事件引擎 | `v2/internal/fall` | 复现 V1 的跟踪、证据、四态状态机和 `FallEvent` | 视频解码、声音、文件 |
|
||||||
| Windows UI | `v2/internal/ui` | Walk 顶部“监控/设置”Tab、渲染最新帧和已计算状态 | 直接读 RTSP、执行 ONNX 或事件规则 |
|
| Windows UI | `v2/internal/ui` | Walk 顶部“监控/设置”Tab、渲染最新帧和已计算状态 | 直接读 RTSP、执行 ONNX 或事件规则 |
|
||||||
|
|||||||
@@ -42,6 +42,15 @@
|
|||||||
- 灵敏度参数(默认放宽,适配低位俯视):`require_rapid_drop`(默认 false,持续水平即可进入疑似,不再强制先检测到快速下移);`require_lower_body`(默认 false,质量门控只强制肩+髋,膝踝可缺);`horizontal_angle_threshold_degrees`(默认 45,躯干与水平夹角≤此值算水平)。放宽后由 `confirm_window_seconds` 作为主要防误报闸——弯腰、捡物不会持续水平到确认窗满。三者进入 `config_version`,可经设置调整并追溯。
|
- 灵敏度参数(默认放宽,适配低位俯视):`require_rapid_drop`(默认 false,持续水平即可进入疑似,不再强制先检测到快速下移);`require_lower_body`(默认 false,质量门控只强制肩+髋,膝踝可缺);`horizontal_angle_threshold_degrees`(默认 45,躯干与水平夹角≤此值算水平)。放宽后由 `confirm_window_seconds` 作为主要防误报闸——弯腰、捡物不会持续水平到确认窗满。三者进入 `config_version`,可经设置调整并追溯。
|
||||||
- 缺少环境变量、模型不存在或哈希不符时,应用显示配置错误,不启动监控。
|
- 缺少环境变量、模型不存在或哈希不符时,应用显示配置错误,不启动监控。
|
||||||
|
|
||||||
|
### V2 Go 运行配置
|
||||||
|
|
||||||
|
`v2/config.example.json` 是 V2 的公开配置形状。它保留 V1 的来源、模型、事件和工件语义,但将 V2 交付依赖显式写入 `model.onnx`、`model.ort_dll` 和 `tools.ffmpeg`/`tools.ffprobe`:
|
||||||
|
|
||||||
|
- `source` 只接受 `id`、`rtsp_url_env`、`transport`、`timeout_seconds` 和 `low_latency`。任何 `url`、`rtsp_url`、`host`、`username` 或 `password` 字段都会被拒绝,即使它们为空;实际流地址只从指定环境变量进入进程内存。
|
||||||
|
- `model.sha256` 必须是锁定 ONNX 的 64 位十六进制 SHA-256;`confidence_threshold` 是 Pose 框过滤阈值。配置相对路径相对于配置文件解析,但只在启动预检时要求实际文件存在。
|
||||||
|
- `event` 字段与 V1 的默认事件语义相同。V2 使用 `source.id`、模型 SHA、模型置信度和这些事件字段生成 `cfg-<sha256>`;URL、工具路径和工件路径均不进入该版本值。
|
||||||
|
- `artifacts.event_dir` 只保存本地 PNG/JSONL 事件证据。V2 发布脚本生成的 `runtime-manifest.json` 记录 ONNX Runtime、FFmpeg、FFprobe 和 ONNX 的来源文件哈希;示例 `runtime-manifest.example.json` 不是交付证据。
|
||||||
|
|
||||||
## 核心数据
|
## 核心数据
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
- Create: `v2/assets/runtime-manifest.example.json`
|
- Create: `v2/assets/runtime-manifest.example.json`
|
||||||
- Modify: `docs/api.md`
|
- Modify: `docs/api.md`
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing configuration tests**
|
- [x] **Step 1: Write failing configuration tests**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func TestLoadResolvesOnlyNamedEnvironmentURL(t *testing.T) {
|
func TestLoadResolvesOnlyNamedEnvironmentURL(t *testing.T) {
|
||||||
@@ -56,12 +56,12 @@ func TestLoadRejectsEmbeddedRTSPURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Run the focused test and verify the missing package fails**
|
- [x] **Step 2: Run the focused test and verify the missing package fails**
|
||||||
|
|
||||||
Run: `Set-Location v2; go test ./internal/config -run TestLoad -v`
|
Run: `Set-Location v2; go test ./internal/config -run TestLoad -v`
|
||||||
Expected: FAIL because `internal/config` does not exist.
|
Expected: FAIL because `internal/config` does not exist.
|
||||||
|
|
||||||
- [ ] **Step 3: Implement the minimal public contract**
|
- [x] **Step 3: Implement the minimal public contract**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -79,12 +79,12 @@ func Load(path string) (Config, error) {
|
|||||||
|
|
||||||
Validate all required files and 64-hex model hash before monitoring starts. Return generic path/configuration errors; do not include the resolved URL in errors. Document the V2 field meanings and unchanged `FallEvent` fields in `docs/api.md`.
|
Validate all required files and 64-hex model hash before monitoring starts. Return generic path/configuration errors; do not include the resolved URL in errors. Document the V2 field meanings and unchanged `FallEvent` fields in `docs/api.md`.
|
||||||
|
|
||||||
- [ ] **Step 4: Re-run focused and package tests**
|
- [x] **Step 4: Re-run focused and package tests**
|
||||||
|
|
||||||
Run: `Set-Location v2; go test ./internal/config -v`
|
Run: `Set-Location v2; go test ./internal/config -v`
|
||||||
Expected: PASS, including missing environment, embedded URL, invalid hash and version-redaction cases.
|
Expected: PASS, including missing environment, embedded URL, invalid hash and version-redaction cases.
|
||||||
|
|
||||||
- [ ] **Step 5: Commit the configuration slice**
|
- [x] **Step 5: Commit the configuration slice**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git add v2/config.example.json v2/internal/config v2/assets/runtime-manifest.example.json docs/api.md
|
git add v2/config.example.json v2/internal/config v2/assets/runtime-manifest.example.json docs/api.md
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"format": "silver-pose-v2-runtime-1",
|
||||||
|
"generated_by": "v2/scripts/build-v2-release.ps1",
|
||||||
|
"note": "A release manifest is generated from explicit local runtime inputs and records their SHA-256 values. This example is not release evidence.",
|
||||||
|
"files": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"source": {
|
||||||
|
"id": "lobby-camera-01",
|
||||||
|
"rtsp_url_env": "SILVER_POSE_RTSP_URL",
|
||||||
|
"transport": "tcp",
|
||||||
|
"timeout_seconds": 5.0,
|
||||||
|
"low_latency": true
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"onnx": "runtime/best.onnx",
|
||||||
|
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"ort_dll": "runtime/onnxruntime.dll",
|
||||||
|
"confidence_threshold": 0.25
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"ffmpeg": "runtime/ffmpeg.exe",
|
||||||
|
"ffprobe": "runtime/ffprobe.exe"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"keypoint_confidence_threshold": 0.4,
|
||||||
|
"suspect_window_seconds": 0.5,
|
||||||
|
"confirm_window_seconds": 1.8,
|
||||||
|
"recovery_window_seconds": 2.0,
|
||||||
|
"cooldown_seconds": 10.0,
|
||||||
|
"require_rapid_drop": false,
|
||||||
|
"require_lower_body": false,
|
||||||
|
"horizontal_angle_threshold_degrees": 45.0
|
||||||
|
},
|
||||||
|
"artifacts": {
|
||||||
|
"event_dir": "../artifacts/events"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
// Package config loads the credential-safe V2 runtime configuration.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
sha256Pattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
|
||||||
|
envPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the validated V2 runtime configuration. Source.URL is resolved
|
||||||
|
// only in memory from an environment variable and must never be logged.
|
||||||
|
type Config struct {
|
||||||
|
Source SourceConfig
|
||||||
|
Model ModelConfig
|
||||||
|
Tools ToolConfig
|
||||||
|
Event EventConfig
|
||||||
|
Artifacts ArtifactConfig
|
||||||
|
RuntimeConfigVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceConfig struct {
|
||||||
|
ID string
|
||||||
|
URL string
|
||||||
|
RTSPURLEnv string
|
||||||
|
Transport string
|
||||||
|
Timeout time.Duration
|
||||||
|
LowLatency bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelConfig struct {
|
||||||
|
ONNXPath string
|
||||||
|
SHA256 string
|
||||||
|
ONNXRuntimeDLLPath string
|
||||||
|
ConfidenceThreshold float32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolConfig struct {
|
||||||
|
FFmpegPath string
|
||||||
|
FFprobePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventConfig struct {
|
||||||
|
KeypointConfidenceThreshold float32
|
||||||
|
SuspectWindowSeconds float64
|
||||||
|
ConfirmWindowSeconds float64
|
||||||
|
RecoveryWindowSeconds float64
|
||||||
|
CooldownSeconds float64
|
||||||
|
RequireRapidDrop bool
|
||||||
|
RequireLowerBody bool
|
||||||
|
HorizontalAngleThresholdDegrees float32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArtifactConfig struct {
|
||||||
|
EventDirectory string
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawConfig struct {
|
||||||
|
Source *rawSource `json:"source"`
|
||||||
|
Model *rawModel `json:"model"`
|
||||||
|
Tools *rawTools `json:"tools"`
|
||||||
|
Event *rawEvent `json:"event"`
|
||||||
|
Artifacts *rawArtifacts `json:"artifacts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawSource struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
RTSPURLEnv string `json:"rtsp_url_env"`
|
||||||
|
URL *string `json:"url"`
|
||||||
|
RTSPURL *string `json:"rtsp_url"`
|
||||||
|
Host *string `json:"host"`
|
||||||
|
Username *string `json:"username"`
|
||||||
|
Password *string `json:"password"`
|
||||||
|
Transport string `json:"transport"`
|
||||||
|
TimeoutSeconds float64 `json:"timeout_seconds"`
|
||||||
|
LowLatency *bool `json:"low_latency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawModel struct {
|
||||||
|
ONNX string `json:"onnx"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
ONNXRuntimeDLL string `json:"ort_dll"`
|
||||||
|
ConfidenceThreshold float32 `json:"confidence_threshold"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawTools struct {
|
||||||
|
FFmpeg string `json:"ffmpeg"`
|
||||||
|
FFprobe string `json:"ffprobe"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawEvent struct {
|
||||||
|
KeypointConfidenceThreshold float32 `json:"keypoint_confidence_threshold"`
|
||||||
|
SuspectWindowSeconds float64 `json:"suspect_window_seconds"`
|
||||||
|
ConfirmWindowSeconds float64 `json:"confirm_window_seconds"`
|
||||||
|
RecoveryWindowSeconds float64 `json:"recovery_window_seconds"`
|
||||||
|
CooldownSeconds float64 `json:"cooldown_seconds"`
|
||||||
|
RequireRapidDrop bool `json:"require_rapid_drop"`
|
||||||
|
RequireLowerBody bool `json:"require_lower_body"`
|
||||||
|
HorizontalAngleThresholdDegrees float32 `json:"horizontal_angle_threshold_degrees"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawArtifacts struct {
|
||||||
|
EventDirectory string `json:"event_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads a public/local V2 config. It rejects embedded URLs and credentials,
|
||||||
|
// then resolves the configured source environment variable in memory.
|
||||||
|
func Load(path string) (Config, error) {
|
||||||
|
configPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("resolve config path: %w", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
var raw rawConfig
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return Config{}, fmt.Errorf("parse config: %w", err)
|
||||||
|
}
|
||||||
|
if raw.Source == nil || raw.Model == nil || raw.Tools == nil || raw.Event == nil || raw.Artifacts == nil {
|
||||||
|
return Config{}, fmt.Errorf("config requires source, model, tools, event and artifacts objects")
|
||||||
|
}
|
||||||
|
if raw.Source.URL != nil || raw.Source.RTSPURL != nil || raw.Source.Host != nil || raw.Source.Username != nil || raw.Source.Password != nil {
|
||||||
|
return Config{}, fmt.Errorf("source must define rtsp_url_env only; embedded URL and credentials are not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
source, err := resolveSource(*raw.Source)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
model, err := resolveModel(configPath, *raw.Model)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
tools, err := resolveTools(configPath, *raw.Tools)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
event, err := resolveEvent(*raw.Event)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
eventDirectory, err := resolvePath(configPath, raw.Artifacts.EventDirectory, "artifacts.event_dir")
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
config := Config{
|
||||||
|
Source: source,
|
||||||
|
Model: model,
|
||||||
|
Tools: tools,
|
||||||
|
Event: event,
|
||||||
|
Artifacts: ArtifactConfig{EventDirectory: eventDirectory},
|
||||||
|
}
|
||||||
|
config.RuntimeConfigVersion, err = runtimeConfigVersion(config)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSource(raw rawSource) (SourceConfig, error) {
|
||||||
|
if strings.TrimSpace(raw.ID) == "" {
|
||||||
|
return SourceConfig{}, fmt.Errorf("source.id must be non-empty")
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(raw.RTSPURLEnv)
|
||||||
|
if !envPattern.MatchString(name) {
|
||||||
|
return SourceConfig{}, fmt.Errorf("source.rtsp_url_env must be an environment variable name")
|
||||||
|
}
|
||||||
|
url, found := os.LookupEnv(name)
|
||||||
|
if !found || strings.TrimSpace(url) == "" {
|
||||||
|
return SourceConfig{}, fmt.Errorf("missing source environment variable: %s", name)
|
||||||
|
}
|
||||||
|
transport := strings.ToLower(strings.TrimSpace(raw.Transport))
|
||||||
|
if transport == "" {
|
||||||
|
transport = "tcp"
|
||||||
|
}
|
||||||
|
if transport != "tcp" && transport != "udp" {
|
||||||
|
return SourceConfig{}, fmt.Errorf("source.transport must be tcp or udp")
|
||||||
|
}
|
||||||
|
timeout := raw.TimeoutSeconds
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 5
|
||||||
|
}
|
||||||
|
if timeout < 0 || timeout > 60 {
|
||||||
|
return SourceConfig{}, fmt.Errorf("source.timeout_seconds must be between 0 and 60")
|
||||||
|
}
|
||||||
|
lowLatency := true
|
||||||
|
if raw.LowLatency != nil {
|
||||||
|
lowLatency = *raw.LowLatency
|
||||||
|
}
|
||||||
|
return SourceConfig{
|
||||||
|
ID: strings.TrimSpace(raw.ID), URL: url, RTSPURLEnv: name, Transport: transport,
|
||||||
|
Timeout: time.Duration(timeout * float64(time.Second)), LowLatency: lowLatency,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveModel(configPath string, raw rawModel) (ModelConfig, error) {
|
||||||
|
if !sha256Pattern.MatchString(raw.SHA256) {
|
||||||
|
return ModelConfig{}, fmt.Errorf("model.sha256 must be a 64-character SHA-256 value")
|
||||||
|
}
|
||||||
|
if raw.ConfidenceThreshold < 0 || raw.ConfidenceThreshold > 1 {
|
||||||
|
return ModelConfig{}, fmt.Errorf("model.confidence_threshold must be between 0 and 1")
|
||||||
|
}
|
||||||
|
onnxPath, err := resolvePath(configPath, raw.ONNX, "model.onnx")
|
||||||
|
if err != nil {
|
||||||
|
return ModelConfig{}, err
|
||||||
|
}
|
||||||
|
dllPath, err := resolvePath(configPath, raw.ONNXRuntimeDLL, "model.ort_dll")
|
||||||
|
if err != nil {
|
||||||
|
return ModelConfig{}, err
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ONNXPath: onnxPath, SHA256: strings.ToLower(raw.SHA256), ONNXRuntimeDLLPath: dllPath,
|
||||||
|
ConfidenceThreshold: raw.ConfidenceThreshold,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveTools(configPath string, raw rawTools) (ToolConfig, error) {
|
||||||
|
ffmpegPath, err := resolvePath(configPath, raw.FFmpeg, "tools.ffmpeg")
|
||||||
|
if err != nil {
|
||||||
|
return ToolConfig{}, err
|
||||||
|
}
|
||||||
|
ffprobePath, err := resolvePath(configPath, raw.FFprobe, "tools.ffprobe")
|
||||||
|
if err != nil {
|
||||||
|
return ToolConfig{}, err
|
||||||
|
}
|
||||||
|
return ToolConfig{FFmpegPath: ffmpegPath, FFprobePath: ffprobePath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveEvent(raw rawEvent) (EventConfig, error) {
|
||||||
|
if raw.KeypointConfidenceThreshold < 0 || raw.KeypointConfidenceThreshold > 1 {
|
||||||
|
return EventConfig{}, fmt.Errorf("event.keypoint_confidence_threshold must be between 0 and 1")
|
||||||
|
}
|
||||||
|
if raw.SuspectWindowSeconds < 0 || raw.SuspectWindowSeconds > 30 {
|
||||||
|
return EventConfig{}, fmt.Errorf("event.suspect_window_seconds must be between 0 and 30")
|
||||||
|
}
|
||||||
|
if raw.ConfirmWindowSeconds < 1 || raw.ConfirmWindowSeconds > 3 {
|
||||||
|
return EventConfig{}, fmt.Errorf("event.confirm_window_seconds must be between 1 and 3")
|
||||||
|
}
|
||||||
|
if raw.RecoveryWindowSeconds <= 0 || raw.RecoveryWindowSeconds > 300 {
|
||||||
|
return EventConfig{}, fmt.Errorf("event.recovery_window_seconds must be between 0 and 300")
|
||||||
|
}
|
||||||
|
if raw.CooldownSeconds < 0 || raw.CooldownSeconds > 3600 {
|
||||||
|
return EventConfig{}, fmt.Errorf("event.cooldown_seconds must be between 0 and 3600")
|
||||||
|
}
|
||||||
|
if raw.HorizontalAngleThresholdDegrees < 0 || raw.HorizontalAngleThresholdDegrees > 90 {
|
||||||
|
return EventConfig{}, fmt.Errorf("event.horizontal_angle_threshold_degrees must be between 0 and 90")
|
||||||
|
}
|
||||||
|
return EventConfig{
|
||||||
|
KeypointConfidenceThreshold: raw.KeypointConfidenceThreshold,
|
||||||
|
SuspectWindowSeconds: raw.SuspectWindowSeconds,
|
||||||
|
ConfirmWindowSeconds: raw.ConfirmWindowSeconds,
|
||||||
|
RecoveryWindowSeconds: raw.RecoveryWindowSeconds,
|
||||||
|
CooldownSeconds: raw.CooldownSeconds,
|
||||||
|
RequireRapidDrop: raw.RequireRapidDrop,
|
||||||
|
RequireLowerBody: raw.RequireLowerBody,
|
||||||
|
HorizontalAngleThresholdDegrees: raw.HorizontalAngleThresholdDegrees,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePath(configPath, value, field string) (string, error) {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return "", fmt.Errorf("%s must be a non-empty path", field)
|
||||||
|
}
|
||||||
|
path := filepath.Clean(value)
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
path = filepath.Join(filepath.Dir(configPath), path)
|
||||||
|
}
|
||||||
|
return filepath.Abs(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeConfigVersion(config Config) (string, error) {
|
||||||
|
payload := struct {
|
||||||
|
SourceID string `json:"source_id"`
|
||||||
|
ModelSHA256 string `json:"model_sha256"`
|
||||||
|
ConfidenceThreshold float32 `json:"confidence_threshold"`
|
||||||
|
Event EventConfig `json:"event"`
|
||||||
|
}{
|
||||||
|
SourceID: config.Source.ID, ModelSHA256: config.Model.SHA256,
|
||||||
|
ConfidenceThreshold: config.Model.ConfidenceThreshold, Event: config.Event,
|
||||||
|
}
|
||||||
|
canonical, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("encode runtime config version: %w", err)
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(canonical)
|
||||||
|
return "cfg-" + hex.EncodeToString(digest[:]), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
|
||||||
|
func TestLoadResolvesOnlyNamedEnvironmentURL(t *testing.T) {
|
||||||
|
t.Setenv("SILVER_POSE_RTSP_URL", "rtsp://operator:secret@192.0.2.9/Streaming/Channels/101")
|
||||||
|
|
||||||
|
cfg, err := Load(writeConfig(t, validConfigJSON()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Source.URL != "rtsp://operator:secret@192.0.2.9/Streaming/Channels/101" {
|
||||||
|
t.Fatalf("resolved source URL = %q", cfg.Source.URL)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(cfg.RuntimeConfigVersion, "cfg-") {
|
||||||
|
t.Fatalf("runtime config version = %q", cfg.RuntimeConfigVersion)
|
||||||
|
}
|
||||||
|
if strings.Contains(cfg.RuntimeConfigVersion, "secret") {
|
||||||
|
t.Fatalf("runtime config version leaked a credential: %q", cfg.RuntimeConfigVersion)
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(cfg.Model.ONNXPath) || !filepath.IsAbs(cfg.Tools.FFmpegPath) {
|
||||||
|
t.Fatalf("relative paths were not resolved: %#v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsEmbeddedRTSPURL(t *testing.T) {
|
||||||
|
t.Setenv("SILVER_POSE_RTSP_URL", "rtsp://operator:secret@192.0.2.9/Streaming/Channels/101")
|
||||||
|
configJSON := strings.Replace(validConfigJSON(), `"rtsp_url_env": "SILVER_POSE_RTSP_URL"`, `"rtsp_url_env": "SILVER_POSE_RTSP_URL", "url": "rtsp://secret"`, 1)
|
||||||
|
|
||||||
|
_, err := Load(writeConfig(t, configJSON))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "rtsp_url_env") {
|
||||||
|
t.Fatalf("embedded URL error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "secret") {
|
||||||
|
t.Fatalf("embedded URL error leaked a credential: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsMissingSourceEnvironment(t *testing.T) {
|
||||||
|
os.Unsetenv("SILVER_POSE_RTSP_URL")
|
||||||
|
|
||||||
|
_, err := Load(writeConfig(t, validConfigJSON()))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "SILVER_POSE_RTSP_URL") {
|
||||||
|
t.Fatalf("missing environment error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsInvalidModelHash(t *testing.T) {
|
||||||
|
t.Setenv("SILVER_POSE_RTSP_URL", "rtsp://operator:secret@192.0.2.9/Streaming/Channels/101")
|
||||||
|
|
||||||
|
_, err := Load(writeConfig(t, strings.Replace(validConfigJSON(), testSHA256, "invalid", 1)))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "model.sha256") {
|
||||||
|
t.Fatalf("invalid hash error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeConfig(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func validConfigJSON() string {
|
||||||
|
return `{
|
||||||
|
"source": {
|
||||||
|
"id": "lobby-camera-01",
|
||||||
|
"rtsp_url_env": "SILVER_POSE_RTSP_URL",
|
||||||
|
"transport": "tcp",
|
||||||
|
"timeout_seconds": 5,
|
||||||
|
"low_latency": true
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"onnx": "assets/best.onnx",
|
||||||
|
"sha256": "` + testSHA256 + `",
|
||||||
|
"ort_dll": "runtime/onnxruntime.dll",
|
||||||
|
"confidence_threshold": 0.25
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"ffmpeg": "runtime/ffmpeg.exe",
|
||||||
|
"ffprobe": "runtime/ffprobe.exe"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"keypoint_confidence_threshold": 0.4,
|
||||||
|
"suspect_window_seconds": 0.5,
|
||||||
|
"confirm_window_seconds": 1.8,
|
||||||
|
"recovery_window_seconds": 2,
|
||||||
|
"cooldown_seconds": 10,
|
||||||
|
"require_rapid_drop": false,
|
||||||
|
"require_lower_body": false,
|
||||||
|
"horizontal_angle_threshold_degrees": 45
|
||||||
|
},
|
||||||
|
"artifacts": { "event_dir": "../artifacts/events" }
|
||||||
|
}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user