diff --git a/agent/internal/httpapi/uiconfig.go b/agent/internal/httpapi/uiconfig.go index b2b4d6a..4032c71 100644 --- a/agent/internal/httpapi/uiconfig.go +++ b/agent/internal/httpapi/uiconfig.go @@ -71,6 +71,28 @@ func (s *Server) handleConfigUIState(w http.ResponseWriter, r *http.Request) { root, _ := readUIRootConfig(s.agentCfg.ConfigPath) + mode := "instances" + warnings := []string{} + if st, err := os.Stat(s.agentCfg.ConfigPath); err != nil { + if os.IsNotExist(err) { + warnings = append(warnings, "config file not found: "+s.agentCfg.ConfigPath) + } else { + warnings = append(warnings, "config stat failed: "+err.Error()) + } + } else if st.IsDir() { + warnings = append(warnings, "config path is a directory: "+s.agentCfg.ConfigPath) + } + instances := root.Instances + // If current config is graphs-only, do a best-effort import so GUI can edit. + if len(instances) == 0 && len(root.Graphs) > 0 { + mode = "graphs_imported" + inst, warn := inferInstancesFromGraphs(root.Graphs) + if len(inst) > 0 { + instances = inst + } + warnings = append(warnings, warn...) + } + // Force templates/instances-only view. // templates = union(current templates, built-in templates) tplKeys := map[string]bool{} @@ -90,12 +112,412 @@ func (s *Server) handleConfigUIState(w http.ResponseWriter, r *http.Request) { "ok": true, "global": root.Global, "queue": root.Queue, - "instances": root.Instances, + "instances": instances, "templates": mergedTpls, + "mode": mode, + "warnings": warnings, } writeJSON(w, http.StatusOK, resp) } +func inferInstancesFromGraphs(graphs []map[string]any) ([]UIInstance, []string) { + instances := []UIInstance{} + warnings := []string{} + used := map[string]int{} + + for _, g := range graphs { + gname := asString(g["name"]) + nodes := asSlice(g["nodes"]) + if len(nodes) == 0 { + continue + } + + // Try templates in priority order. + if inst, ok := inferFaceDetRecog(gname, nodes); ok { + inst.Name = uniquify(inst.Name, used) + instances = append(instances, inst) + continue + } + if inst, ok := inferFaceDet(gname, nodes); ok { + inst.Name = uniquify(inst.Name, used) + instances = append(instances, inst) + continue + } + if inst, ok := inferYoloAlarmMinio(gname, nodes); ok { + inst.Name = uniquify(inst.Name, used) + instances = append(instances, inst) + continue + } + if inst, ok := inferYolo(gname, nodes); ok { + inst.Name = uniquify(inst.Name, used) + instances = append(instances, inst) + continue + } + if inst, ok := inferTranscode(gname, nodes); ok { + inst.Name = uniquify(inst.Name, used) + instances = append(instances, inst) + continue + } + + if gname == "" { + gname = "noname" + } + warnings = append(warnings, "graph not recognized for templates/instances import: "+gname) + } + + return instances, warnings +} + +func inferTranscode(graphName string, nodes []any) (UIInstance, bool) { + in := findNodeByType(nodes, "input_rtsp") + pub := findNodeByType(nodes, "publish") + if in == nil || pub == nil { + return UIInstance{}, false + } + if findNodeByType(nodes, "ai_yolo") != nil || findNodeByType(nodes, "ai_face_det") != nil || findNodeByType(nodes, "ai_face_recog") != nil { + return UIInstance{}, false + } + name := deriveNameFromPublish(pub, graphName) + params := map[string]any{} + params["name"] = name + if url := asString(in["url"]); url != "" { + params["url"] = url + } else { + return UIInstance{}, false + } + params["fps"] = asIntOr(in["fps"], asIntOr(pub["fps"], 25)) + params["src_w"] = asIntOr(in["width"], 1920) + params["src_h"] = asIntOr(in["height"], 1080) + params["gop"] = asIntOr(pub["gop"], 50) + params["bitrate_kbps"] = asIntOr(pub["bitrate_kbps"], 2000) + applyPublishOutputsToParams(pub, params) + return UIInstance{Name: name, Template: "transcode_rtsp_hls", Params: params}, true +} + +func inferYolo(graphName string, nodes []any) (UIInstance, bool) { + in := findNodeByType(nodes, "input_rtsp") + ai := findNodeByType(nodes, "ai_yolo") + pub := findNodeByType(nodes, "publish") + if in == nil || ai == nil || pub == nil { + return UIInstance{}, false + } + if findNodeByType(nodes, "alarm") != nil { + return UIInstance{}, false + } + name := deriveNameFromPublish(pub, graphName) + params := map[string]any{} + params["name"] = name + url := asString(in["url"]) + model := asString(ai["model_path"]) + if url == "" || model == "" { + return UIInstance{}, false + } + params["url"] = url + params["model_path"] = model + params["fps"] = asIntOr(in["fps"], asIntOr(pub["fps"], 25)) + params["src_w"] = asIntOr(in["width"], 1920) + params["src_h"] = asIntOr(in["height"], 1080) + params["gop"] = asIntOr(pub["gop"], 50) + params["bitrate_kbps"] = asIntOr(pub["bitrate_kbps"], 2000) + applyPublishOutputsToParams(pub, params) + return UIInstance{Name: name, Template: "yolo_rtsp_hls", Params: params}, true +} + +func inferYoloAlarmMinio(graphName string, nodes []any) (UIInstance, bool) { + in := findNodeByType(nodes, "input_rtsp") + ai := findNodeByType(nodes, "ai_yolo") + alarm := findNodeByType(nodes, "alarm") + pub := findNodeByType(nodes, "publish") + if in == nil || ai == nil || alarm == nil || pub == nil { + return UIInstance{}, false + } + name := deriveNameFromPublish(pub, graphName) + params := map[string]any{} + params["name"] = name + url := asString(in["url"]) + model := asString(ai["model_path"]) + if url == "" || model == "" { + return UIInstance{}, false + } + params["url"] = url + params["model_path"] = model + params["fps"] = asIntOr(in["fps"], asIntOr(pub["fps"], 25)) + params["src_w"] = asIntOr(in["width"], 1920) + params["src_h"] = asIntOr(in["height"], 1080) + params["gop"] = asIntOr(pub["gop"], 50) + params["bitrate_kbps"] = asIntOr(pub["bitrate_kbps"], 2000) + applyPublishOutputsToParams(pub, params) + + // cooldown + if rules := asSlice(alarm["rules"]); len(rules) > 0 { + if r0 := asMap(rules[0]); r0 != nil { + params["cooldown_ms"] = asIntOr(r0["cooldown_ms"], 3000) + } + } + + // minio from snapshot/clip upload + actions := asMap(alarm["actions"]) + upl := firstMinioUpload(actions) + if upl == nil { + return UIInstance{}, false + } + ep := asString(upl["endpoint"]) + bucket := asString(upl["bucket"]) + ak := asString(upl["access_key"]) + sk := asString(upl["secret_key"]) + if ep == "" || bucket == "" || ak == "" || sk == "" { + return UIInstance{}, false + } + params["minio_endpoint"] = ep + params["minio_bucket"] = bucket + params["minio_ak"] = ak + params["minio_sk"] = sk + if region := asString(upl["region"]); region != "" { + params["minio_region"] = region + } else { + params["minio_region"] = "us-east-1" + } + + return UIInstance{Name: name, Template: "yolo_alarm_minio", Params: params}, true +} + +func inferFaceDet(graphName string, nodes []any) (UIInstance, bool) { + in := findNodeByType(nodes, "input_rtsp") + det := findNodeByType(nodes, "ai_face_det") + pub := findNodeByType(nodes, "publish") + if in == nil || det == nil || pub == nil { + return UIInstance{}, false + } + if findNodeByType(nodes, "ai_face_recog") != nil { + return UIInstance{}, false + } + name := deriveNameFromPublish(pub, graphName) + params := map[string]any{} + params["name"] = name + url := asString(in["url"]) + model := asString(det["model_path"]) + if url == "" || model == "" { + return UIInstance{}, false + } + params["url"] = url + params["det_model_path"] = model + params["fps"] = asIntOr(in["fps"], asIntOr(pub["fps"], 25)) + params["src_w"] = asIntOr(in["width"], 1920) + params["src_h"] = asIntOr(in["height"], 1080) + params["gop"] = asIntOr(pub["gop"], 50) + params["bitrate_kbps"] = asIntOr(pub["bitrate_kbps"], 2000) + applyPublishOutputsToParams(pub, params) + return UIInstance{Name: name, Template: "face_det_rtsp_hls", Params: params}, true +} + +func inferFaceDetRecog(graphName string, nodes []any) (UIInstance, bool) { + in := findNodeByType(nodes, "input_rtsp") + det := findNodeByType(nodes, "ai_face_det") + rec := findNodeByType(nodes, "ai_face_recog") + pub := findNodeByType(nodes, "publish") + if in == nil || det == nil || rec == nil || pub == nil { + return UIInstance{}, false + } + name := deriveNameFromPublish(pub, graphName) + params := map[string]any{} + params["name"] = name + url := asString(in["url"]) + detModel := asString(det["model_path"]) + recModel := asString(rec["model_path"]) + if url == "" || detModel == "" || recModel == "" { + return UIInstance{}, false + } + params["url"] = url + params["det_model_path"] = detModel + params["recog_model_path"] = recModel + params["fps"] = asIntOr(in["fps"], asIntOr(pub["fps"], 25)) + params["src_w"] = asIntOr(in["width"], 1920) + params["src_h"] = asIntOr(in["height"], 1080) + params["gop"] = asIntOr(pub["gop"], 50) + params["bitrate_kbps"] = asIntOr(pub["bitrate_kbps"], 2000) + applyPublishOutputsToParams(pub, params) + + if th := asMap(rec["threshold"]); th != nil { + params["thr_accept"] = asFloatOr(th["accept"], 0.45) + params["thr_margin"] = asFloatOr(th["margin"], 0.05) + } else { + params["thr_accept"] = 0.45 + params["thr_margin"] = 0.05 + } + if gal := asMap(rec["gallery"]); gal != nil { + if p := asString(gal["path"]); p != "" { + params["gallery_path"] = p + } else { + params["gallery_path"] = "./models/face_gallery.db" + } + } else { + params["gallery_path"] = "./models/face_gallery.db" + } + + return UIInstance{Name: name, Template: "face_det_recog_rtsp_hls", Params: params}, true +} + +func findNodeByType(nodes []any, typ string) map[string]any { + for _, n := range nodes { + m := asMap(n) + if m == nil { + continue + } + if asString(m["type"]) == typ { + return m + } + } + return nil +} + +func deriveNameFromPublish(pub map[string]any, fallback string) string { + outs := asSlice(pub["outputs"]) + for _, o := range outs { + om := asMap(o) + if om == nil { + continue + } + if asString(om["proto"]) != "rtsp_server" { + continue + } + p := asString(om["path"]) + if strings.HasPrefix(p, "/live/") { + n := strings.TrimPrefix(p, "/live/") + n = strings.Trim(n, "/") + if n != "" { + return n + } + } + } + if strings.TrimSpace(fallback) != "" { + return fallback + } + return "cam" +} + +func applyPublishOutputsToParams(pub map[string]any, params map[string]any) { + outs := asSlice(pub["outputs"]) + for _, o := range outs { + om := asMap(o) + if om == nil { + continue + } + proto := asString(om["proto"]) + if proto == "rtsp_server" { + params["rtsp_port"] = asIntOr(om["port"], asIntOr(params["rtsp_port"], 8555)) + } + if proto == "hls" { + if p := asString(om["path"]); p != "" { + params["hls_path"] = p + } + } + } +} + +func firstMinioUpload(actions map[string]any) map[string]any { + if actions == nil { + return nil + } + // Prefer snapshot.upload. + if snap := asMap(actions["snapshot"]); snap != nil { + if up := asMap(snap["upload"]); up != nil { + if asString(up["type"]) == "minio" { + return up + } + } + } + if clip := asMap(actions["clip"]); clip != nil { + if up := asMap(clip["upload"]); up != nil { + if asString(up["type"]) == "minio" { + return up + } + } + } + return nil +} + +func uniquify(name string, used map[string]int) string { + base := name + if base == "" { + base = "cam" + } + if used[base] == 0 { + used[base] = 1 + return base + } + used[base]++ + return fmt.Sprintf("%s_%d", base, used[base]) +} + +func asMap(v any) map[string]any { + if v == nil { + return nil + } + if m, ok := v.(map[string]any); ok { + return m + } + return nil +} + +func asSlice(v any) []any { + if v == nil { + return nil + } + if s, ok := v.([]any); ok { + return s + } + return nil +} + +func asString(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return strings.TrimSpace(s) + } + return "" +} + +func asIntOr(v any, def int) int { + if v == nil { + return def + } + switch x := v.(type) { + case float64: + return int(x) + case int: + return x + case int32: + return int(x) + case int64: + return int(x) + case string: + // ignore parse errors + return def + default: + return def + } +} + +func asFloatOr(v any, def float64) float64 { + if v == nil { + return def + } + switch x := v.(type) { + case float64: + return x + case int: + return float64(x) + case int32: + return float64(x) + case int64: + return float64(x) + default: + return def + } +} + func (s *Server) handleConfigUIPlan(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { errorJSON(w, http.StatusMethodNotAllowed, "method not allowed") diff --git a/agent/rk3588-agent b/agent/rk3588-agent index 621365c..716692a 100644 Binary files a/agent/rk3588-agent and b/agent/rk3588-agent differ