diff --git a/agent/cmd/rk3588-agent/main.go b/agent/cmd/rk3588-agent/main.go index 9d4217b..1605cbd 100644 --- a/agent/cmd/rk3588-agent/main.go +++ b/agent/cmd/rk3588-agent/main.go @@ -123,6 +123,7 @@ func main() { BuildID: BuildID, BuildType: BuildType, GitSHA: GitSHA, + ConfigPath: cfg.Agent.ConfigPath, } log.Info("udp discovery listening on :" + strconv.Itoa(cfg.Agent.DiscoveryPort)) if err := resp.Run(ctx); err != nil { diff --git a/agent/internal/discovery/discovery.go b/agent/internal/discovery/discovery.go index a977dd9..b365fa4 100644 --- a/agent/internal/discovery/discovery.go +++ b/agent/internal/discovery/discovery.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" "net" + "os" + "path/filepath" "strings" "time" @@ -25,6 +27,7 @@ type Responder struct { BuildID string BuildType string GitSHA string + ConfigPath string } type discoverReq struct { @@ -46,6 +49,13 @@ type discoverReply struct { BuildID string `json:"build_id"` BuildType string `json:"build_type"` GitSHA string `json:"git_sha"` + ConfigID string `json:"config_id,omitempty"` + ConfigVersion string `json:"config_version,omitempty"` + Template string `json:"template,omitempty"` + Profile string `json:"profile,omitempty"` + Overlays []string `json:"overlays,omitempty"` + InstanceName string `json:"instance_name,omitempty"` + InstanceDisplayName string `json:"instance_display_name,omitempty"` UptimeSec int64 `json:"uptime_sec"` } @@ -110,6 +120,15 @@ func (r *Responder) Run(ctx context.Context) error { GitSHA: r.GitSHA, UptimeSec: sysinfo.UptimeSec(), } + if summary := readConfigMetadataSummary(r.ConfigPath); summary != nil { + reply.ConfigID = summary.ConfigID + reply.ConfigVersion = summary.ConfigVersion + reply.Template = summary.Template + reply.Profile = summary.Profile + reply.Overlays = copyStringSlice(summary.Overlays) + reply.InstanceName = summary.InstanceName + reply.InstanceDisplayName = summary.InstanceDisplayName + } b, _ := json.Marshal(reply) out := Magic + "\n" + string(b) + "\n" _, _ = conn.WriteToUDP([]byte(out), remote) @@ -128,3 +147,84 @@ func split2lines(s string) (string, string, bool) { } return line1, line2, true } + +type configMetadataSummary struct { + ConfigID string + ConfigVersion string + Template string + Profile string + Overlays []string + InstanceName string + InstanceDisplayName string +} + +func readConfigMetadataSummary(path string) *configMetadataSummary { + if strings.TrimSpace(path) == "" { + return nil + } + b, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return nil + } + var root struct { + Metadata map[string]any `json:"metadata"` + } + if err := json.Unmarshal(b, &root); err != nil { + return nil + } + return metadataSummaryFromMap(root.Metadata) +} + +func metadataSummaryFromMap(metadata map[string]any) *configMetadataSummary { + if len(metadata) == 0 { + return nil + } + summary := &configMetadataSummary{ + ConfigID: stringValue(metadata["config_id"]), + ConfigVersion: stringValue(metadata["config_version"]), + Template: stringValue(metadata["template"]), + Profile: stringValue(metadata["profile"]), + Overlays: stringSliceValue(metadata["overlays"]), + } + if names := stringSliceValue(metadata["instance_names"]); len(names) > 0 { + summary.InstanceName = names[0] + } + if names := stringSliceValue(metadata["instance_display_names"]); len(names) > 0 { + summary.InstanceDisplayName = names[0] + } + return summary +} + +func stringValue(v any) string { + s, _ := v.(string) + return strings.TrimSpace(s) +} + +func stringSliceValue(v any) []string { + switch vv := v.(type) { + case []string: + return copyStringSlice(vv) + case []any: + out := make([]string, 0, len(vv)) + for _, item := range vv { + if s, ok := item.(string); ok { + s = strings.TrimSpace(s) + if s != "" { + out = append(out, s) + } + } + } + return out + default: + return nil + } +} + +func copyStringSlice(v []string) []string { + if len(v) == 0 { + return nil + } + out := make([]string, len(v)) + copy(out, v) + return out +} diff --git a/agent/internal/discovery/discovery_test.go b/agent/internal/discovery/discovery_test.go new file mode 100644 index 0000000..13d462f --- /dev/null +++ b/agent/internal/discovery/discovery_test.go @@ -0,0 +1,97 @@ +package discovery + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "testing" + "time" +) + +func TestResponderIncludesConfigSummaryInReply(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "media-server.json") + body := []byte(`{"metadata":{"config_id":"cfg-1","config_version":"v1","template":"workshop_face_shoe_alarm","profile":"local_3588_test","overlays":["face_debug"],"instance_names":["cam1"],"instance_display_names":["视觉识别终端-A厂区"]},"instances":[]}`) + if err := os.WriteFile(cfgPath, body, 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + port := freeUDPPort(t) + resp := &Responder{ + Port: port, + DeviceID: "dev-1", + DeviceName: "rk3588_orangepi5plus", + Hostname: "orangepi5plus", + AgentPort: 9100, + MediaPort: 9000, + Version: "0.1.0", + BuildID: "20260420.001", + BuildType: "release", + GitSHA: "abc1234", + ConfigPath: cfgPath, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + _ = resp.Run(ctx) + }() + time.Sleep(100 * time.Millisecond) + + conn, err := net.DialUDP("udp4", nil, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port}) + if err != nil { + t.Fatalf("dial udp: %v", err) + } + defer conn.Close() + if err := conn.SetDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatalf("set deadline: %v", err) + } + + req := Magic + "\n" + `{"type":"discover","req_id":"req-1","reply_port":0}` + "\n" + if _, err := conn.Write([]byte(req)); err != nil { + t.Fatalf("write req: %v", err) + } + + buf := make([]byte, 4096) + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("read reply: %v", err) + } + _, line2, ok := split2lines(string(buf[:n])) + if !ok { + t.Fatalf("invalid reply: %q", string(buf[:n])) + } + + var reply map[string]any + if err := json.Unmarshal([]byte(line2), &reply); err != nil { + t.Fatalf("decode reply: %v", err) + } + if reply["config_id"] != "cfg-1" { + t.Fatalf("config_id = %#v", reply["config_id"]) + } + if reply["template"] != "workshop_face_shoe_alarm" { + t.Fatalf("template = %#v", reply["template"]) + } + if reply["profile"] != "local_3588_test" { + t.Fatalf("profile = %#v", reply["profile"]) + } + if reply["instance_display_name"] != "视觉识别终端-A厂区" { + t.Fatalf("instance_display_name = %#v", reply["instance_display_name"]) + } + overlays, ok := reply["overlays"].([]any) + if !ok || len(overlays) != 1 || overlays[0] != "face_debug" { + t.Fatalf("overlays = %#v", reply["overlays"]) + } +} + +func freeUDPPort(t *testing.T) int { + t.Helper() + l, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + if err != nil { + t.Fatalf("listen udp: %v", err) + } + defer l.Close() + return l.LocalAddr().(*net.UDPAddr).Port +} diff --git a/agent/internal/httpapi/config_status_test.go b/agent/internal/httpapi/config_status_test.go index d7d4af6..b000d69 100644 --- a/agent/internal/httpapi/config_status_test.go +++ b/agent/internal/httpapi/config_status_test.go @@ -46,7 +46,7 @@ func (f fakeProcessController) RollbackBinary(string) (procctl.BinaryUpdateResul func TestHandleConfigStatusReportsMetadataHashAndMediaStatus(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") - body := []byte(`{"metadata":{"config_id":"cfg-1","config_version":"v1"},"instances":[]}`) + body := []byte(`{"metadata":{"config_id":"cfg-1","config_version":"v1","template":"workshop_face_shoe_alarm","profile":"local_3588_test","overlays":["face_debug"],"instance_names":["cam1"],"instance_display_names":["视觉识别终端-A厂区"]},"instances":[]}`) if err := os.WriteFile(cfgPath, body, 0o644); err != nil { t.Fatalf("write config: %v", err) } @@ -85,6 +85,19 @@ func TestHandleConfigStatusReportsMetadataHashAndMediaStatus(t *testing.T) { if metadata["config_id"] != "cfg-1" || metadata["config_version"] != "v1" { t.Fatalf("metadata = %#v", metadata) } + if got["template"] != "workshop_face_shoe_alarm" { + t.Fatalf("template = %#v", got["template"]) + } + if got["profile"] != "local_3588_test" { + t.Fatalf("profile = %#v", got["profile"]) + } + if got["instance_display_name"] != "视觉识别终端-A厂区" { + t.Fatalf("instance_display_name = %#v", got["instance_display_name"]) + } + overlays, ok := got["overlays"].([]any) + if !ok || len(overlays) != 1 || overlays[0] != "face_debug" { + t.Fatalf("overlays = %#v", got["overlays"]) + } sum := sha256.Sum256(body) wantSHA := hex.EncodeToString(sum[:]) if got["sha256"] != wantSHA { @@ -102,3 +115,58 @@ func TestHandleConfigStatusReportsMetadataHashAndMediaStatus(t *testing.T) { t.Fatalf("previous_config_path = %#v", got["previous_config_path"]) } } + +func TestHandleInfoIncludesCurrentConfigSummary(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + body := []byte(`{"metadata":{"config_id":"cfg-1","config_version":"v1","template":"workshop_face_shoe_alarm","profile":"local_3588_test","overlays":["face_debug","production_quiet"],"instance_names":["cam1"],"instance_display_names":["视觉识别终端-A厂区"]},"instances":[]}`) + if err := os.WriteFile(cfgPath, body, 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + s := &Server{ + agentCfg: config.AgentConfig{ + ConfigPath: cfgPath, + DeviceName: "rk3588_orangepi5plus", + }, + deviceID: "dev-1", + hostname: "orangepi5plus", + agentPort: 9100, + mediaPort: 9000, + version: "0.1.0", + buildID: "20260420.001", + buildType: "release", + gitSHA: "abc1234", + } + + req := httptest.NewRequest(http.MethodGet, "/v1/info", nil) + rr := httptest.NewRecorder() + s.handleInfo(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status code: got %d body=%s", rr.Code, rr.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got["config_id"] != "cfg-1" { + t.Fatalf("config_id = %#v", got["config_id"]) + } + if got["config_version"] != "v1" { + t.Fatalf("config_version = %#v", got["config_version"]) + } + if got["template"] != "workshop_face_shoe_alarm" { + t.Fatalf("template = %#v", got["template"]) + } + if got["profile"] != "local_3588_test" { + t.Fatalf("profile = %#v", got["profile"]) + } + if got["instance_display_name"] != "视觉识别终端-A厂区" { + t.Fatalf("instance_display_name = %#v", got["instance_display_name"]) + } + overlays, ok := got["overlays"].([]any) + if !ok || len(overlays) != 2 { + t.Fatalf("overlays = %#v", got["overlays"]) + } +} diff --git a/agent/internal/httpapi/extras.go b/agent/internal/httpapi/extras.go index c54d0d5..e394ef9 100644 --- a/agent/internal/httpapi/extras.go +++ b/agent/internal/httpapi/extras.go @@ -27,6 +27,16 @@ type configFileStatus struct { Error string `json:"error,omitempty"` } +type configMetadataSummary struct { + ConfigID string + ConfigVersion string + Template string + Profile string + Overlays []string + InstanceName string + InstanceDisplayName string +} + func defaultAuditPath(baseDir string) string { if strings.TrimSpace(baseDir) == "" { return filepath.Join("logs", "agent_audit.jsonl") @@ -216,6 +226,29 @@ func (s *Server) configStatusPayload() map[string]any { } if len(current.Metadata) > 0 { resp["metadata"] = current.Metadata + if summary := metadataSummaryFromMap(current.Metadata); summary != nil { + if summary.ConfigID != "" { + resp["config_id"] = summary.ConfigID + } + if summary.ConfigVersion != "" { + resp["config_version"] = summary.ConfigVersion + } + if summary.Template != "" { + resp["template"] = summary.Template + } + if summary.Profile != "" { + resp["profile"] = summary.Profile + } + if len(summary.Overlays) > 0 { + resp["overlays"] = copyStringSlice(summary.Overlays) + } + if summary.InstanceName != "" { + resp["instance_name"] = summary.InstanceName + } + if summary.InstanceDisplayName != "" { + resp["instance_display_name"] = summary.InstanceDisplayName + } + } } if current.Error != "" { resp["error"] = current.Error @@ -236,6 +269,69 @@ func (s *Server) configStatusPayload() map[string]any { return resp } +func readConfigMetadataSummary(path string) *configMetadataSummary { + st := readConfigFileStatus(path) + if len(st.Metadata) == 0 { + return nil + } + return metadataSummaryFromMap(st.Metadata) +} + +func metadataSummaryFromMap(metadata map[string]any) *configMetadataSummary { + if len(metadata) == 0 { + return nil + } + summary := &configMetadataSummary{ + ConfigID: stringValue(metadata["config_id"]), + ConfigVersion: stringValue(metadata["config_version"]), + Template: stringValue(metadata["template"]), + Profile: stringValue(metadata["profile"]), + Overlays: stringSliceValue(metadata["overlays"]), + } + + if names := stringSliceValue(metadata["instance_names"]); len(names) > 0 { + summary.InstanceName = names[0] + } + if names := stringSliceValue(metadata["instance_display_names"]); len(names) > 0 { + summary.InstanceDisplayName = names[0] + } + return summary +} + +func stringValue(v any) string { + s, _ := v.(string) + return strings.TrimSpace(s) +} + +func stringSliceValue(v any) []string { + switch vv := v.(type) { + case []string: + return copyStringSlice(vv) + case []any: + out := make([]string, 0, len(vv)) + for _, item := range vv { + if s, ok := item.(string); ok { + s = strings.TrimSpace(s) + if s != "" { + out = append(out, s) + } + } + } + return out + default: + return nil + } +} + +func copyStringSlice(v []string) []string { + if len(v) == 0 { + return nil + } + out := make([]string, len(v)) + copy(out, v) + return out +} + func readConfigFileStatus(path string) configFileStatus { out := configFileStatus{Path: filepath.ToSlash(path)} if strings.TrimSpace(path) == "" { diff --git a/agent/internal/httpapi/server.go b/agent/internal/httpapi/server.go index 203761f..d86cc36 100644 --- a/agent/internal/httpapi/server.go +++ b/agent/internal/httpapi/server.go @@ -69,6 +69,13 @@ type InfoResponse struct { GitSHA string `json:"git_sha"` ConfigPath string `json:"config_path"` PreviousConfigPath string `json:"previous_config_path"` + ConfigID string `json:"config_id,omitempty"` + ConfigVersion string `json:"config_version,omitempty"` + Template string `json:"template,omitempty"` + Profile string `json:"profile,omitempty"` + Overlays []string `json:"overlays,omitempty"` + InstanceName string `json:"instance_name,omitempty"` + InstanceDisplayName string `json:"instance_display_name,omitempty"` UptimeSec int64 `json:"uptime_sec"` } @@ -168,6 +175,15 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { PreviousConfigPath: s.agentCfg.ConfigPath + ".last_good.json", UptimeSec: sysinfo.UptimeSec(), } + if summary := readConfigMetadataSummary(s.agentCfg.ConfigPath); summary != nil { + resp.ConfigID = summary.ConfigID + resp.ConfigVersion = summary.ConfigVersion + resp.Template = summary.Template + resp.Profile = summary.Profile + resp.Overlays = copyStringSlice(summary.Overlays) + resp.InstanceName = summary.InstanceName + resp.InstanceDisplayName = summary.InstanceDisplayName + } writeJSON(w, http.StatusOK, resp) } diff --git a/agent/rk3588-agent_linux_arm64 b/agent/rk3588-agent_linux_arm64 index 2856115..fec4330 100755 Binary files a/agent/rk3588-agent_linux_arm64 and b/agent/rk3588-agent_linux_arm64 differ diff --git a/configs/profiles/local_3588_test.json b/configs/profiles/local_3588_test.json index a2acd96..2f91eb4 100644 --- a/configs/profiles/local_3588_test.json +++ b/configs/profiles/local_3588_test.json @@ -10,9 +10,14 @@ "name": "cam1", "template": "workshop_face_shoe_alarm", "params": { + "display_name": "视觉识别终端-A厂区", + "device_code": "rk3588-a-001", + "site_name": "A厂区", "rtsp_url": "rtsp://10.0.0.49:8554/cam", - "rga_gate": "full_pipeline_1080p", - "face_gallery_path": "./models/face_gallery.db", + "publish_hls_path": "./web/hls/cam1/index.m3u8", + "publish_rtsp_port": 8555, + "publish_rtsp_path": "/live/cam1", + "channel_no": "cam1", "minio_endpoint": "http://10.0.0.49:9000", "minio_bucket": "myminio", "minio_access_key": "admin", diff --git a/configs/templates/workshop_face_shoe_alarm.json b/configs/templates/workshop_face_shoe_alarm.json index 1f98666..1e760cc 100644 --- a/configs/templates/workshop_face_shoe_alarm.json +++ b/configs/templates/workshop_face_shoe_alarm.json @@ -37,7 +37,7 @@ "dst_packed": true, "resize_mode": "stretch", "keep_ratio": false, - "rga_gate": "${rga_gate}", + "rga_gate": "main_pipeline_rga", "use_rga": true }, { @@ -98,7 +98,7 @@ }, "gallery": { "backend": "sqlite", - "path": "${face_gallery_path}", + "path": "./models/face_gallery.db", "load_on_start": true, "dtype": "auto" }, @@ -117,7 +117,7 @@ 5 ], "use_rga": true, - "rga_gate": "${rga_gate}", + "rga_gate": "main_pipeline_rga", "rga_max_inflight": 4, "dst_packed": true, "use_dma_input": true, @@ -177,7 +177,7 @@ 6 ], "use_rga": true, - "rga_gate": "${rga_gate}", + "rga_gate": "main_pipeline_rga", "rga_max_inflight": 4, "dst_packed": true, "use_dma_input": false, @@ -263,7 +263,7 @@ "dst_h": 1080, "dst_format": "nv12", "resize_mode": "stretch", - "rga_gate": "${rga_gate}", + "rga_gate": "main_pipeline_rga", "use_rga": true }, { @@ -311,13 +311,13 @@ "outputs": [ { "proto": "hls", - "path": "./web/hls//index.m3u8", + "path": "${publish_hls_path}", "segment_sec": 2 }, { "proto": "rtsp_server", - "port": 8555, - "path": "/live/" + "port": "${publish_rtsp_port}", + "path": "${publish_rtsp_path}" } ] }, @@ -433,7 +433,7 @@ "getTokenUrl": "${external_get_token_url}", "putMessageUrl": "${external_put_message_url}", "tenantCode": "${tenant_code}", - "channelNo": "${name}", + "channelNo": "${channel_no}", "timeout_ms": 3000, "include_media_url": true, "token_header": "X-Access-Token", diff --git a/docs/config_guide.md b/docs/config_guide.md index 4d4600f..bd323a9 100644 --- a/docs/config_guide.md +++ b/docs/config_guide.md @@ -694,6 +694,28 @@ python tools/render_config.py \ | `configs/overlays/` | 测试或运行场景覆盖,例如 debug、阈值、频率 | | `configs/generated/` | 渲染产物,不手工维护,不提交生成的 JSON | +当前主线 profile 的设备级字段建议集中在: + +- `display_name` +- `device_code` +- `site_name` +- `rtsp_url` +- `publish_hls_path` +- `publish_rtsp_port` +- `publish_rtsp_path` +- `channel_no` + +当前主线 template 中已经固定或内收的默认值: + +- `gallery.path` 固定为 `./models/face_gallery.db` +- `rga_gate` 在 `workshop_face_shoe_alarm` 中固定为 `main_pipeline_rga` + +这意味着: + +- `face_gallery_path` 不再作为普通 profile 配置项 +- `rga_gate` 不再作为该模板的普通 profile 配置项 +- profile 主要承载设备身份、输入源和输出发布参数 + `--overlay` 可以指定多次,后面的 overlay 会覆盖前面的同名字段。建议: - 生产:不加测试敏感 overlay,或只加 `production_quiet.json`。 diff --git a/docs/design/ConfigTemplate_Rendering_Design.md b/docs/design/ConfigTemplate_Rendering_Design.md index 1030aab..a202213 100644 --- a/docs/design/ConfigTemplate_Rendering_Design.md +++ b/docs/design/ConfigTemplate_Rendering_Design.md @@ -32,12 +32,30 @@ configs/ - HLS/RTSP 发布 - 告警、截图、录像、MinIO 上传、External API 上传 -模板中保留 DAG、插件结构和生产默认阈值。设备差异通过占位符表达,例如 `${rtsp_url}`、`${face_gallery_path}`、`${minio_endpoint}`、`${external_get_token_url}`。短视频验证、临时放宽阈值或打开高频日志,应通过 overlay 完成,不应直接改模板。 +模板中保留 DAG、插件结构和生产默认阈值。设备差异通过占位符表达,例如 `${rtsp_url}`、`${publish_hls_path}`、`${publish_rtsp_port}`、`${publish_rtsp_path}`、`${channel_no}`。共享外部服务参数继续保留为模板参数,例如 `${minio_endpoint}`、`${external_get_token_url}`。短视频验证、临时放宽阈值或打开高频日志,应通过 overlay 完成,不应直接改模板。 + +当前主线模板已明确内收两类默认值: + +- `gallery.path` 固定为 `./models/face_gallery.db`,由部署时复制到安装目录,不再要求 profile 显式填写 `face_gallery_path` +- `rga_gate` 在 `workshop_face_shoe_alarm` 中固定为 `main_pipeline_rga`,不再作为主 profile 字段 ## Profile `configs/profiles/local_3588_test.json` 描述具体设备或测试盒子的参数。多台设备或多路相机应新增 profile 或在同一 profile 中新增 instances,而不是复制完整 pipeline。 +当前主 profile 推荐只保留设备级字段,例如: + +- `display_name` +- `device_code` +- `site_name` +- `rtsp_url` +- `publish_hls_path` +- `publish_rtsp_port` +- `publish_rtsp_path` +- `channel_no` + +像 `face_gallery_path`、`rga_gate` 这类工程运行默认值,不再进入普通 profile 主字段。 + ## Overlay Overlay 用于测试或运行场景覆盖: diff --git a/docs/superpowers/specs/2026-04-20-profile-centered-config-design.md b/docs/superpowers/specs/2026-04-20-profile-centered-config-design.md index 3c4a425..a5f0f33 100644 --- a/docs/superpowers/specs/2026-04-20-profile-centered-config-design.md +++ b/docs/superpowers/specs/2026-04-20-profile-centered-config-design.md @@ -1,123 +1,120 @@ -# Profile-Centered Config Design +# 以 Profile 为中心的配置设计 -## Purpose +## 目的 -Clarify the field boundary between `template`, `profile`, and `overlay` so the backend management system can treat `profile` as a first-class asset instead of a simple dropdown. +明确 `template`、`profile`、`overlay` 三层之间的字段边界,使后台管理系统能够把 `profile` 当作一等配置资产,而不是仅仅把它当成一个下拉选项。 -The target operating model is: +目标运行模型如下: -- one physical device maps to one profile -- templates stay reusable across multiple devices and sites -- overlays stay limited to debug, sensitivity, and temporary runtime modes -- internal media-server implementation parameters remain hidden from normal users +- 一台物理设备对应一份 profile +- template 可以在多台设备、多个现场之间复用 +- overlay 只负责 debug、灵敏度和临时运行模式 +- media-server 的内部实现参数默认不暴露给普通用户 -This design builds on the existing template rendering workflow and does not return to hand-maintained full JSON files. +该设计建立在现有模板渲染工作流之上,不会回到手工维护多份完整 JSON 的旧模式。 -## Current Context +## 当前背景 -The current maintained config source is: +当前受维护的配置来源为: -- template: `configs/templates/workshop_face_shoe_alarm.json` -- profile: `configs/profiles/local_3588_test.json` -- overlays: `configs/overlays/*.json` -- generated runtime config: `configs/generated/*.json` +- template:`configs/templates/workshop_face_shoe_alarm.json` +- profile:`configs/profiles/local_3588_test.json` +- overlays:`configs/overlays/*.json` +- 生成配置:`configs/generated/*.json` -Current reality from the config files: +从现有配置文件看,当前实际情况是: -- `template` already carries the pipeline skeleton, node graph, models, alarm structure, and default thresholds -- `overlay` already behaves well as debug/test/quiet mode overrides -- `profile` already contains some device-specific fields such as `rtsp_url`, but it also mixes in shared external-service settings +- `template` 已经承载了流水线骨架、节点图、模型、告警结构和默认阈值 +- `overlay` 当前的定位基本正确,已经主要用于 debug / 测试 / 安静运行模式 +- `profile` 已经开始承载设备独特属性,例如 `rtsp_url`,但同时又混入了共享的外部服务配置 -That mix is the main problem. It prevents the backend from cleanly exposing profile management as "device/site identity and connection settings". +这类混放是当前的核心问题。它会让后台很难把 profile 清晰地表达成“设备/现场身份与设备级接入配置”。 -## Design Goals +## 设计目标 -1. Make `profile` the canonical home of one device's identity and per-device connection settings. -2. Keep shared scheme-level settings reusable through templates. -3. Keep overlays small and operational. -4. Prevent low-level implementation knobs from leaking into the user-facing config UI. -5. Support multiple templates, with each template remaining flexible enough to connect to different external services and storage backends. +1. 让 `profile` 成为单台设备身份与设备级接入配置的唯一归属层。 +2. 让方案级共享配置继续通过 template 复用。 +3. 保持 overlay 小而纯粹,专注运行模式。 +4. 阻止底层实现细节泄漏到面向用户的配置界面。 +5. 支持多模板并存,且每个模板都能灵活对接不同的外部服务和存储后端。 -## Field Ownership +## 字段归属边界 ### Template -`template` owns scheme-level shared configuration. +`template` 负责方案级共享配置。 -Typical contents: +典型内容包括: -- DAG structure: `nodes`, `edges` -- plugin/node selection -- shared model defaults -- shared algorithm defaults -- shared alarm action structure -- shared external service settings -- shared storage settings -- default publish protocol structure +- DAG 结构:`nodes`、`edges` +- 插件/节点选择 +- 共享模型默认值 +- 共享算法默认值 +- 共享告警动作结构 +- 共享外部服务配置 +- 共享存储配置 +- 默认发布协议结构 -Examples from `workshop_face_shoe_alarm.json`: +以 `workshop_face_shoe_alarm.json` 为例,适合归属于 template 的内容包括: -- node types and node IDs -- model paths and model sizes -- tracker mode and default tracking thresholds -- alarm rule structure -- snapshot/clip upload structure -- external API action structure -- publish outputs structure +- 节点类型和节点 ID +- 模型路径和模型尺寸 +- 跟踪器模式及默认阈值 +- 告警规则结构 +- 快照/录像上传结构 +- External API 动作结构 +- 发布输出结构 -Important rule: +重要规则: -- shared services in `template` must remain editable template parameters -- they must not be treated as hard-coded constants +- template 中的共享服务字段必须保持“可编辑模板参数”的属性 +- 不能把它们当成写死的常量 -That allows multiple templates to point at different MinIO or external alarm services when needed. +这样才能支持不同 template 对接不同 MinIO 或不同外部告警服务。 ### Profile -`profile` owns per-device identity and per-device runtime bindings. +`profile` 负责设备级身份和设备级运行绑定。 -One profile corresponds to one physical device. +一份 profile 对应一台物理设备。 -Typical contents: +典型内容包括: -- business display name -- site/area identity -- device-local input source -- device-local publish output parameters -- device-local resource paths -- any device-bound runtime identifier needed by external systems +- 业务显示名 +- 站点身份 +- 设备本地输入源 +- 设备本地发布输出参数 +- 设备本地资源路径 +- 外部系统需要的设备级业务标识 -Current fields that should stay in profile: +当前已经明显适合保留在 profile 的字段: - `rtsp_url` -- `rga_gate` -- `face_gallery_path` -New profile-level fields recommended by this design: +本设计建议新增的 profile 字段: - `display_name` - `device_code` - `site_name` -- `area_name` - `publish_hls_path` - `publish_rtsp_port` - `publish_rtsp_path` - `channel_no` -The backend device list should primarily show `display_name`. Technical identifiers such as `device_id` and `hostname` belong in device detail views. +后台设备列表应优先展示 `display_name`。像 `device_id`、`hostname` 这样的技术标识,应该收纳到设备详情页。 ### Overlay -`overlay` owns short-lived operating modes. +`overlay` 负责短周期运行模式。 -Allowed uses: +允许的用途包括: -- debug switches -- sensitive test thresholds -- quiet production mode -- temporary validation behavior +- debug 开关 +- 测试灵敏度 +- 安静生产模式 +- 临时验证行为 -Examples already aligned with this rule: +当前已经比较符合这一定位的 overlay: - `face_debug.json` - `shoe_debug.json` @@ -125,65 +122,82 @@ Examples already aligned with this rule: - `shoe_test_sensitive.json` - `production_quiet.json` -Overlays should not carry: +overlay 不应承载: -- device identity -- device-local video sources -- per-device publish host/path/port -- long-lived external service ownership +- 设备身份 +- 设备本地视频源 +- 每台设备自己的输出主机/路径/端口 +- 长期稳定的外部服务归属 -### Hidden Internal Defaults +### 程序内部默认参数与高级设置 -These should stay out of normal backend config editing: +以下内容不应进入普通后台主配置编辑: - `cpu_affinity` -- queue implementation details +- queue 实现细节 - `rga_max_inflight` -- low-level tensor/input/output assumptions -- internal plugin glue parameters -- other fields that are easy to misconfigure and mainly matter to engineering +- 底层 tensor / 输入输出假设 +- 插件之间的内部 glue 参数 +- 其他容易配坏、主要面向工程调试的字段 -These may still exist in template JSON, but they should not be presented as normal user-editable fields. +这些参数应按两类处理: -## Standard Profile Model +#### 1. 程序内部默认参数 -Recommended logical sections for one profile: +如果某些参数在当前部署模式下应固定,则应直接回收到程序默认、模板默认或部署默认,不再暴露成配置字段。 -### 1. Device Identity +当前明确建议内收的字段: + +- `face_gallery_path` + - 应由部署流程将人脸库放到约定默认目录 + - 不应允许在后台中修改 +- `rga_gate` + - 如果最终确认始终固定使用同一组 RGA 资源分组,则应回收到模板默认或程序默认 + - 当前 `workshop_face_shoe_alarm` 已固定为 `main_pipeline_rga` + +#### 2. 高级设置 + +如果某些字段仅在工程调试、特殊部署或排障时才需要保留,则可以在 UI 中进入“高级设置”,并默认折叠,不进入日常运维主流程。 + +## 标准 Profile 模型 + +建议把单个 profile 组织为以下几组字段: + +### 1. 设备身份 - `display_name` - `device_code` - `site_name` -- `area_name` -### 2. Input Source +### 2. 输入源 - `rtsp_url` -### 3. Publish Output +### 3. 输出发布 - `publish_hls_path` - `publish_rtsp_port` - `publish_rtsp_path` - `channel_no` -Reasoning: +原因: -- publish output is per-device, not shared -- it includes device-specific IP/host/port/path semantics -- it often determines how downstream systems consume that specific box +- 发布输出是设备级属性,不是共享属性 +- 它天然包含设备自己的 IP / 主机 / 端口 / 路径语义 +- 它往往直接决定外部系统如何消费这台盒子的输出流 -### 4. Device-Local Resources +### 4. 设备本地资源 -- `face_gallery_path` -- `rga_gate` -- future device-local model or mount paths if needed +- 仅保留未来确有设备差异的本地资源路径 +- 当前不建议把 `face_gallery_path` 暴露为 profile 字段 +- 当前不建议把 `rga_gate` 暴露为 profile 字段 +- 只有在后续确认不同设备之间确实存在差异时,才考虑进入高级设置 -## Template Parameter Model +## Template 参数模型 -Shared services should remain template-level parameters, not profile fields, when they are scheme-level common settings. +当某些外部服务属于“方案级共享配置”时,它们应保留在 template,而不是塞进每台设备自己的 profile。 -Recommended template-managed shared fields: +建议由 template 管理的共享字段包括: - `minio_endpoint` - `minio_bucket` @@ -193,18 +207,17 @@ Recommended template-managed shared fields: - `external_put_message_url` - `tenant_code` -Reasoning: +原因: -- these are often shared across a deployment scheme -- repeating them in every profile creates duplication and drift -- different templates may legitimately use different shared services, so they must remain editable per template +- 这类配置通常在同一类部署方案中是共享的 +- 如果把它们复制进每台设备的 profile,会造成重复和漂移 +- 不同 template 仍然可能对接不同共享服务,因此它们必须保持 template 级可编辑,而不是写死 -## Render Parameter Migration +## 渲染参数迁移方向 -The following current parameters already exist in the template: +当前 template 中已经存在的占位符包括: - `${rtsp_url}` -- `${rga_gate}` - `${face_gallery_path}` - `${minio_endpoint}` - `${minio_bucket}` @@ -215,15 +228,13 @@ The following current parameters already exist in the template: - `${tenant_code}` - `${name}` -This design recommends the following next migration: +本设计建议下一步按以下方向迁移: -### Keep as profile-driven placeholders +### 保留为 profile 驱动占位符 - `${rtsp_url}` -- `${rga_gate}` -- `${face_gallery_path}` -### Keep as template-driven placeholders +### 保留为 template 驱动占位符 - `${minio_endpoint}` - `${minio_bucket}` @@ -233,99 +244,105 @@ This design recommends the following next migration: - `${external_put_message_url}` - `${tenant_code}` -### Add new profile-driven placeholders +当前 `workshop_face_shoe_alarm` 已将 `rga_gate` 从占位符体系中移除,改回模板默认。 -- `${display_name}` if needed by backend-facing metadata or labels +当前主线模板已将人脸库路径固定为 `./models/face_gallery.db`,后续由部署默认目录解决。 + +### 新增为 profile 驱动占位符 + +- `${display_name}`,如后续用于后台展示元数据或业务标签 - `${publish_hls_path}` - `${publish_rtsp_port}` - `${publish_rtsp_path}` - `${channel_no}` -## Publish Output Adjustment +## 输出发布部分的调整 -Current template publish outputs are structurally correct, but some fields should stop being fixed literals. +当前 template 中的 publish 输出结构本身是合理的,但其中部分字段不应再写成固定值。 -Recommended change: +建议调整为: -- keep output protocol structure in template -- move per-device output values to profile-backed placeholders +- 输出协议结构继续保留在 template +- 每台设备不同的输出值改为引用 profile 字段 -Suggested publish section direction: +建议演进方向: -- HLS output path: use `${publish_hls_path}` -- RTSP server port: use `${publish_rtsp_port}` -- RTSP server path: use `${publish_rtsp_path}` -- external API `channelNo`: use `${channel_no}` instead of `${name}` +- HLS 输出路径:使用 `${publish_hls_path}` +- RTSP server 端口:使用 `${publish_rtsp_port}` +- RTSP server 路径:使用 `${publish_rtsp_path}` +- External API 中的 `channelNo`:改为使用 `${channel_no}`,不再直接使用 `${name}` -Reasoning: +原因: -- `name` currently mixes instance identity with external business channel semantics -- external platform channel identity should be explicit -- publish paths and ports are clearly per-device runtime settings +- 当前 `${name}` 把实例名和外部业务通道语义混在了一起 +- 对外平台通道号应当是明确字段,而不是靠实例名隐式复用 +- 输出路径和端口显然属于单设备运行属性 -## Backend Management Implications +## 对后台管理模型的影响 -This boundary implies the backend should evolve toward the following asset model: +这套字段边界意味着后台应逐步演进成以下资产模型: ### Device -Operational object discovered from the agent: +由 agent 发现得到的运行对象: - device ID - hostname -- agent reachability -- media-server state -- current applied config metadata +- agent 可达性 +- media-server 状态 +- 当前应用配置元数据 ### Profile -Managed configuration asset bound one-to-one to a device: +与设备一对一绑定的配置资产: -- edited in a dedicated profile detail page -- contains device identity, input, output, and local resource settings +- 在独立的 profile 详情页中编辑 +- 承载设备身份、输入、输出和本地资源设置 ### Template -Managed reusable scheme asset: +可复用的方案资产: -- edited in a dedicated template detail page -- contains pipeline structure and shared service parameters +- 在独立的 template 详情页中编辑 +- 承载流水线结构和共享服务参数 ### Overlay -Managed operational mode asset: +运行模式资产: -- selected during preview/apply workflows -- not used as the primary place to hold stable device identity +- 在预览/下发流程中进行选择 +- 不作为长期设备身份配置的主要承载层 -## UI Consequences +## 对 UI 的直接含义 -The backend should stop treating profile as only a dropdown. +后台不应再把 profile 仅仅当成一个下拉框。 -Minimum intended direction: +最小方向应当是: -1. profile becomes a first-class asset under config management -2. every device detail page should clearly show which profile is bound to it -3. device display name should come from profile, not from raw agent defaults -4. preview/apply pages only select profile; they should not be the main place where profile data is edited +1. profile 成为配置资产中的一等对象 +2. 每台设备详情页都要明确显示当前绑定的 profile +3. 设备显示名应该来自 profile,而不是直接来自 agent 默认命名 +4. 预览/应用页面只负责“选择 profile”,而不应成为 profile 的主要编辑入口 -## Migration Direction +## 迁移路径 -This design does not require immediate large-scale config rewrites. A practical migration path is: +这套设计不要求立刻重写全部配置。更实际的迁移路径是: -1. define the standard profile field model -2. parameterize publish output fields in the template -3. move shared external-service settings from profile into template-managed parameters -4. introduce backend profile pages and device-profile binding UI -5. keep overlays unchanged except for normal cleanup +1. 先定义标准 profile 字段模型 +2. 把 template 中的发布输出字段参数化 +3. 把共享外部服务配置从 profile 迁到 template 参数 +4. 将 `face_gallery_path` 收回部署默认目录 +5. 若模板中的 `rga_gate` 已固定,则收回模板默认或程序默认 +6. 在后台引入 profile 页面和设备-profile 绑定界面 +7. overlay 保持现状,仅做必要清理 -## Out of Scope +## 暂不覆盖的内容 -This design does not yet define: +本设计当前不包含以下内容: -- the full backend profile editor UI layout -- template editor UI schema generation -- automatic migration tooling for old profile JSON files -- permission models for editing templates vs profiles +- 完整的后台 profile 编辑页布局 +- template 编辑器的 UI schema 生成方式 +- 老 profile JSON 的自动迁移工具 +- template 与 profile 的权限模型 -Those should be handled in the next design and implementation planning step. +这些内容应放到下一轮设计和实施计划中继续展开。 diff --git a/tests/test_render_config.py b/tests/test_render_config.py index ed68505..fc04c04 100644 --- a/tests/test_render_config.py +++ b/tests/test_render_config.py @@ -1,4 +1,5 @@ import importlib.util +import json import pathlib import sys import tempfile @@ -18,6 +19,45 @@ def load_module(): class RenderConfigTest(unittest.TestCase): + def test_render_supports_profile_publish_placeholders(self): + template_path = REPO_ROOT / "configs" / "templates" / "workshop_face_shoe_alarm.json" + profile_path = REPO_ROOT / "configs" / "profiles" / "local_3588_test.json" + + template = json.loads(template_path.read_text(encoding="utf-8")) + profile = json.loads(profile_path.read_text(encoding="utf-8")) + + publish_node = next(node for node in template["template"]["nodes"] if node["id"] == "publish") + alarm_node = next(node for node in template["template"]["nodes"] if node["id"] == "alarm") + params = profile["instances"][0]["params"] + + self.assertEqual(publish_node["outputs"][0]["path"], "${publish_hls_path}") + self.assertEqual(publish_node["outputs"][1]["port"], "${publish_rtsp_port}") + self.assertEqual(publish_node["outputs"][1]["path"], "${publish_rtsp_path}") + self.assertEqual(alarm_node["actions"]["external_api"]["channelNo"], "${channel_no}") + + self.assertIn("publish_hls_path", params) + self.assertIn("publish_rtsp_port", params) + self.assertIn("publish_rtsp_path", params) + self.assertIn("channel_no", params) + + def test_render_profile_no_longer_requires_face_gallery_path(self): + profile_path = REPO_ROOT / "configs" / "profiles" / "local_3588_test.json" + profile = json.loads(profile_path.read_text(encoding="utf-8")) + params = profile["instances"][0]["params"] + + self.assertNotIn("face_gallery_path", params) + self.assertNotIn("rga_gate", params) + + def test_render_template_internalizes_clear_rga_gate_name(self): + template_path = REPO_ROOT / "configs" / "templates" / "workshop_face_shoe_alarm.json" + template = json.loads(template_path.read_text(encoding="utf-8")) + nodes = {node["id"]: node for node in template["template"]["nodes"]} + + self.assertEqual(nodes["pre_rgb"]["rga_gate"], "main_pipeline_rga") + self.assertEqual(nodes["person_det"]["rga_gate"], "main_pipeline_rga") + self.assertEqual(nodes["shoe_det"]["rga_gate"], "main_pipeline_rga") + self.assertEqual(nodes["pre_osd"]["rga_gate"], "main_pipeline_rga") + def test_renders_profile_and_overlay(self): module = load_module() template = { @@ -188,6 +228,54 @@ class RenderConfigTest(unittest.TestCase): self.assertEqual(rendered["metadata"]["overlays"], []) self.assertEqual(rendered["metadata"]["rendered_by"], "test") + def test_render_metadata_includes_instance_identity_summary(self): + module = load_module() + with tempfile.TemporaryDirectory() as tmp_dir: + tmp = pathlib.Path(tmp_dir) + template_path = tmp / "template.json" + profile_path = tmp / "profile.json" + + template_path.write_text( + """ + { + "name": "pipeline", + "template": { + "nodes": [{"id": "in", "type": "input_rtsp"}], + "edges": [] + } + } + """, + encoding="utf-8", + ) + profile_path.write_text( + """ + { + "name": "local_3588_test", + "instances": [ + { + "name": "cam1", + "params": { + "display_name": "视觉识别终端-A厂区", + "rtsp_url": "rtsp://example/cam1" + } + } + ] + } + """, + encoding="utf-8", + ) + + rendered = module.render( + template_path, + profile_path, + [], + metadata={"config_id": "cfg1", "rendered_by": "test"}, + ) + + self.assertEqual(rendered["metadata"]["profile"], "local_3588_test") + self.assertEqual(rendered["metadata"]["instance_names"], ["cam1"]) + self.assertEqual(rendered["metadata"]["instance_display_names"], ["视觉识别终端-A厂区"]) + if __name__ == "__main__": unittest.main() diff --git a/tools/render_config.py b/tools/render_config.py index 39dcf0d..a9f925f 100644 --- a/tools/render_config.py +++ b/tools/render_config.py @@ -147,11 +147,26 @@ def render( root = apply_overlay(root, load_json(overlay_path)) if metadata is not None: profile_name = str(profile.get("name") or profile_path.stem).strip() + instance_names: list[str] = [] + instance_display_names: list[str] = [] + for instance in root.get("instances", []): + if not isinstance(instance, dict): + continue + name = str(instance.get("name") or "").strip() + if name: + instance_names.append(name) + params = instance.get("params", {}) + if isinstance(params, dict): + display_name = str(params.get("display_name") or "").strip() + if display_name: + instance_display_names.append(display_name) root["metadata"] = { "template": tpl_name, "template_path": template_path.as_posix(), "profile": profile_name, "profile_path": profile_path.as_posix(), + "instance_names": instance_names, + "instance_display_names": instance_display_names, "overlays": [p.stem for p in overlay_paths], "overlay_paths": [p.as_posix() for p in overlay_paths], **copy.deepcopy(metadata),