Adds a 测试连接摄像头 button next to save; it builds the RTSP URL from the current form fields and runs ffprobe (8s timeout) off the UI thread, then shows a result popup (success WxH@fps, or a redacted failure). Exports config.BuildRTSPURL and source.Probe/Redact for it. Cross-compiles for Windows; config/source tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
410 lines
14 KiB
Go
410 lines
14 KiB
Go
// Package config loads the credential-safe V2 runtime configuration.
|
|
package config
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"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"`
|
|
Port *int `json:"port"`
|
|
Channel *string `json:"channel"`
|
|
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 {
|
|
return Config{}, fmt.Errorf("source must define structured host fields or rtsp_url_env, not a full url or rtsp_url")
|
|
}
|
|
|
|
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")
|
|
}
|
|
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
|
|
}
|
|
|
|
resolved := SourceConfig{
|
|
ID: strings.TrimSpace(raw.ID), Transport: transport,
|
|
Timeout: time.Duration(timeout * float64(time.Second)), LowLatency: lowLatency,
|
|
}
|
|
|
|
if raw.Host != nil {
|
|
streamURL, err := structuredSourceURL(raw)
|
|
if err != nil {
|
|
return SourceConfig{}, err
|
|
}
|
|
resolved.URL = streamURL
|
|
return resolved, nil
|
|
}
|
|
|
|
name := strings.TrimSpace(raw.RTSPURLEnv)
|
|
if !envPattern.MatchString(name) {
|
|
return SourceConfig{}, fmt.Errorf("source.rtsp_url_env must be an environment variable name")
|
|
}
|
|
streamURL, found := os.LookupEnv(name)
|
|
if !found || strings.TrimSpace(streamURL) == "" {
|
|
return SourceConfig{}, fmt.Errorf("missing source environment variable: %s", name)
|
|
}
|
|
resolved.URL = streamURL
|
|
resolved.RTSPURLEnv = name
|
|
return resolved, nil
|
|
}
|
|
|
|
// structuredSourceURL builds an RTSP URL from local host/credential fields.
|
|
// net/url percent-encodes the userinfo, so a password with @/:/ does not break
|
|
// parsing. These fields are only permitted in the untracked local config.
|
|
func structuredSourceURL(raw rawSource) (string, error) {
|
|
host := strings.TrimSpace(*raw.Host)
|
|
if host == "" {
|
|
return "", fmt.Errorf("source.host must be non-empty")
|
|
}
|
|
if raw.Username == nil || strings.TrimSpace(*raw.Username) == "" {
|
|
return "", fmt.Errorf("source.username must be non-empty")
|
|
}
|
|
if raw.Password == nil || *raw.Password == "" {
|
|
return "", fmt.Errorf("source.password must be non-empty")
|
|
}
|
|
port := 554
|
|
if raw.Port != nil {
|
|
port = *raw.Port
|
|
}
|
|
if port < 1 || port > 65535 {
|
|
return "", fmt.Errorf("source.port must be between 1 and 65535")
|
|
}
|
|
channel := "101"
|
|
if raw.Channel != nil && strings.TrimSpace(*raw.Channel) != "" {
|
|
channel = strings.TrimSpace(*raw.Channel)
|
|
}
|
|
return BuildRTSPURL(host, port, strings.TrimSpace(*raw.Username), *raw.Password, channel), nil
|
|
}
|
|
|
|
// BuildRTSPURL assembles a Hikvision RTSP URL with percent-encoded credentials.
|
|
func BuildRTSPURL(host string, port int, username, password, channel string) string {
|
|
address := url.URL{
|
|
Scheme: "rtsp",
|
|
User: url.UserPassword(username, password),
|
|
Host: fmt.Sprintf("%s:%d", host, port),
|
|
Path: "/Streaming/Channels/" + channel,
|
|
}
|
|
return address.String()
|
|
}
|
|
|
|
// updateLocalConfig rewrites one untracked local config file, preserving blocks
|
|
// the caller does not touch. Only ever call this on config.local.json.
|
|
func updateLocalConfig(configPath string, mutate func(map[string]interface{})) error {
|
|
document := map[string]interface{}{}
|
|
if data, err := os.ReadFile(configPath); err == nil {
|
|
if err := json.Unmarshal(data, &document); err != nil {
|
|
return fmt.Errorf("parse local config: %w", err)
|
|
}
|
|
}
|
|
mutate(document)
|
|
out, err := json.MarshalIndent(document, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode local config: %w", err)
|
|
}
|
|
return os.WriteFile(configPath, append(out, '\n'), 0o600)
|
|
}
|
|
|
|
func objectOf(document map[string]interface{}, key string) map[string]interface{} {
|
|
if existing, ok := document[key].(map[string]interface{}); ok {
|
|
return existing
|
|
}
|
|
created := map[string]interface{}{}
|
|
document[key] = created
|
|
return created
|
|
}
|
|
|
|
// WriteLocalCameraSource persists a structured camera source (with plaintext
|
|
// credentials) into the untracked local config. The public example must never
|
|
// receive these fields.
|
|
func WriteLocalCameraSource(configPath, id, host string, port int, channel, username, password, transport string, timeoutSeconds float64, lowLatency bool) error {
|
|
return updateLocalConfig(configPath, func(document map[string]interface{}) {
|
|
document["source"] = map[string]interface{}{
|
|
"id": id, "host": host, "port": port, "channel": channel,
|
|
"username": username, "password": password,
|
|
"transport": transport, "timeout_seconds": timeoutSeconds, "low_latency": lowLatency,
|
|
}
|
|
})
|
|
}
|
|
|
|
// WriteLocalEventTuning persists the common detection parameters into the
|
|
// untracked local config, preserving other event/model fields.
|
|
func WriteLocalEventTuning(configPath string, keypointConfidence, confirmWindowSeconds, horizontalAngleDegrees, modelConfidence float64, requireRapidDrop, requireLowerBody bool) error {
|
|
return updateLocalConfig(configPath, func(document map[string]interface{}) {
|
|
event := objectOf(document, "event")
|
|
event["keypoint_confidence_threshold"] = keypointConfidence
|
|
event["confirm_window_seconds"] = confirmWindowSeconds
|
|
event["horizontal_angle_threshold_degrees"] = horizontalAngleDegrees
|
|
event["require_rapid_drop"] = requireRapidDrop
|
|
event["require_lower_body"] = requireLowerBody
|
|
objectOf(document, "model")["confidence_threshold"] = modelConfidence
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|