Files
silver_pose/v2/internal/config/config.go
T

301 lines
9.7 KiB
Go

// 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
}