safesight-control/internal/service/auto_config.go
tian 700d964524 feat: device capabilities from agent /v1/capabilities
Agent 新增 /v1/capabilities 接口,返回设备检测能力列表。
Managerd 控制台查询此接口,仅显示设备真实支持的功能。

- Agent: capabilities.go — 根据插件节点类型映射能力
- Managerd: pageConsole 查询 /v1/capabilities 过滤可选功能
- ConsoleFeature 增加 Description 字段,hover 显示说明
- 旧 agent 无此接口时回退为显示已持久化的功能
2026-05-12 13:52:53 +08:00

608 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
)
// FeatureDef describes a detection feature that users can toggle on/off.
type FeatureDef struct {
Key string // unique key, e.g. "face", "shoe"
Label string // human-readable label
Templates []string // template names supporting this feature, ordered by coverage (broadest first)
}
// FeatureRegistry centralises which templates cover which detection features.
type FeatureRegistry struct {
features map[string]*FeatureDef
// nodeTypeRequirements maps feature key → required media-server node types.
// If a device lacks any required node type, the feature is unavailable.
nodeTypeRequirements map[string][]string
}
// NewFeatureRegistry creates a registry with the built-in feature definitions.
func NewFeatureRegistry() *FeatureRegistry {
defs := []FeatureDef{
{
Key: "face",
Label: "人脸识别",
Templates: []string{"std_workshop_face_recognition_shoe_alarm", "std_face_recognition_stream"},
},
{
Key: "shoe",
Label: "劳保鞋检测",
Templates: []string{"std_workshop_face_recognition_shoe_alarm", "std_workshoe_detection_stream"},
},
{
Key: "helmet",
Label: "安全帽检测",
Templates: []string{}, // 模型就绪后再补充
},
{
Key: "smoking",
Label: "抽烟检测",
Templates: []string{}, // 模型就绪后再补充
},
{
Key: "intrusion",
Label: "区域入侵",
Templates: []string{}, // 模板就绪后再补充
},
}
r := &FeatureRegistry{
features: make(map[string]*FeatureDef, len(defs)),
nodeTypeRequirements: map[string][]string{
"face": {"ai_face_recog"}, // face recognition plugin
"shoe": {"ai_shoe_det"}, // shoe detection plugin
"helmet": {"ai_yolo"}, // YOLO for helmet (model pending)
"smoking": {"action_recog"}, // action recognition for smoking
"intrusion": {"region_event"}, // region-based intrusion
},
}
for i := range defs {
r.features[defs[i].Key] = &defs[i]
}
return r
}
// FeatureKeys returns all registered feature keys in a stable order.
func (r *FeatureRegistry) FeatureKeys() []string {
keys := make([]string, 0, len(r.features))
for k := range r.features {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// Feature returns the definition for a feature key, or nil.
// AvailableFeatures returns the subset of registered features that are supported
// by the given device node types (from /v1/graph-node-types).
// If nodeTypes is nil/empty, all features are considered available (fallback for
// old agents that don't expose node types).
func (r *FeatureRegistry) AvailableFeatures(nodeTypes []string) []FeatureDef {
all := r.AllFeatures()
if len(nodeTypes) == 0 {
return all // no info → assume all available
}
typeSet := make(map[string]bool, len(nodeTypes))
for _, t := range nodeTypes {
typeSet[t] = true
}
out := make([]FeatureDef, 0, len(all))
for _, f := range all {
if r.featureAvailable(f.Key, typeSet) {
out = append(out, f)
}
}
return out
}
func (r *FeatureRegistry) featureAvailable(key string, nodeTypes map[string]bool) bool {
required, ok := r.nodeTypeRequirements[key]
if !ok || len(required) == 0 {
return true // no specific plugin requirement → always available
}
for _, t := range required {
if !nodeTypes[t] {
return false
}
}
return true
}
func (r *FeatureRegistry) Feature(key string) *FeatureDef {
return r.features[key]
}
// AllFeatures returns a copy of all feature definitions sorted by key.
func (r *FeatureRegistry) AllFeatures() []FeatureDef {
out := make([]FeatureDef, 0, len(r.features))
for _, key := range r.FeatureKeys() {
if f := r.features[key]; f != nil {
out = append(out, *f)
}
}
return out
}
// ResolveResult describes the outcome of template resolution.
type ResolveResult struct {
TemplateName string `json:"template_name"` // selected template name; may be a composed name when Composed is true
Composed bool `json:"composed"` // true when no single template covers all features and composition is needed
// internal fields for the template composer (unexported)
composePrimary string `json:"-"`
composeFeatures []string `json:"-"`
}
// ResolveTemplate picks the best template covering all requested features.
//
// Strategy:
// 1. Single-template: find the intersection of all features' template lists.
// Return the first (highest-coverage) match.
// 2. Composition needed: every feature has at least one template but no single
// template covers all. This case will be handled by the template composer
// later — for now we return the broadest template and set Composed=true.
// 3. Error: one or more features have no templates at all.
func (r *FeatureRegistry) ResolveTemplate(featureKeys []string) (*ResolveResult, error) {
if len(featureKeys) == 0 {
return nil, fmt.Errorf("未选择任何检测功能")
}
// Deduplicate and sort
seen := map[string]struct{}{}
cleaned := make([]string, 0, len(featureKeys))
for _, key := range featureKeys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if _, ok := r.features[key]; !ok {
return nil, fmt.Errorf("未知的检测功能: %q", key)
}
cleaned = append(cleaned, key)
}
if len(cleaned) == 0 {
return nil, fmt.Errorf("未选择任何检测功能")
}
sort.Strings(cleaned)
// Collect template lists for each feature.
lists := make([][]string, 0, len(cleaned))
for _, key := range cleaned {
def := r.features[key]
if len(def.Templates) == 0 {
return nil, fmt.Errorf("%s%s的模板尚未就绪", def.Label, def.Key)
}
lists = append(lists, def.Templates)
}
// Find intersection.
intersection := intersectTemplateLists(lists)
if len(intersection) > 0 {
// Pick the most specific template: the one covering the fewest features
// (i.e. listed by the fewest FeatureDefs), not the broadest.
// This ensures selecting only "face" doesn't pull in a face+shoe template.
best := intersection[0]
bestScore := r.templateFeatureCount(best)
for _, tpl := range intersection[1:] {
if score := r.templateFeatureCount(tpl); score < bestScore {
best = tpl
bestScore = score
}
}
return &ResolveResult{
TemplateName: best,
Composed: false,
}, nil
}
// No single template covers all features → composition needed.
// For now, pick the template that covers the most features.
primary := pickBroadestTemplate(lists)
composedName := "auto_" + strings.Join(cleaned, "_")
return &ResolveResult{
TemplateName: composedName,
Composed: true,
composePrimary: primary,
composeFeatures: append([]string(nil), cleaned...),
}, nil
}
// intersectTemplateLists returns templates that appear in every list, preserving
// the order of the first list.
func intersectTemplateLists(lists [][]string) []string {
if len(lists) == 0 {
return nil
}
// Count occurrences across all lists.
counts := map[string]int{}
for _, list := range lists {
lseen := map[string]struct{}{} // dedupe within one list
for _, t := range list {
if _, ok := lseen[t]; ok {
continue
}
lseen[t] = struct{}{}
counts[t]++
}
}
total := len(lists)
// Return in the order of the first list (which is sorted by coverage).
result := make([]string, 0)
seen := map[string]struct{}{}
for _, t := range lists[0] {
if counts[t] == total {
if _, ok := seen[t]; !ok {
seen[t] = struct{}{}
result = append(result, t)
}
}
}
return result
}
// pickBroadestTemplate returns the template that appears earliest in the
// first list (assumed to be sorted by coverage breadth).
func pickBroadestTemplate(lists [][]string) string {
if len(lists) == 0 || len(lists[0]) == 0 {
return ""
}
return lists[0][0]
}
// templateFeatureCount returns how many features list this template.
// Lower = more specific template.
func (r *FeatureRegistry) templateFeatureCount(templateName string) int {
count := 0
for _, def := range r.features {
for _, t := range def.Templates {
if t == templateName {
count++
break
}
}
}
return count
}
func (r *FeatureRegistry) ValidateFeatureKeys(keys []string) error {
seen := map[string]struct{}{}
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if _, ok := r.features[key]; !ok {
return fmt.Errorf("未知的检测功能: %q", key)
}
}
if len(seen) == 0 {
return fmt.Errorf("未选择任何检测功能")
}
return nil
}
// ────────────────────────────────────────────────────────────
// AutoConfigService — one-click configuration pipeline
// ────────────────────────────────────────────────────────────
// AutoConfigRequest is a single device's configuration intent from the console.
type AutoConfigRequest struct {
DeviceID string `json:"device_id"`
Features []string `json:"features"` // feature keys: "face", "shoe", ...
SourceNames []string `json:"source_names"` // assigned video source names
}
// AutoConfigResult reports what happened for a single device.
type AutoConfigResult struct {
DeviceID string `json:"device_id"`
TemplateName string `json:"template_name"`
Composed bool `json:"composed"`
UnitsCreated int `json:"units_created"`
ConfigSHA256 string `json:"config_sha256"`
TaskID string `json:"task_id,omitempty"`
Error string `json:"error,omitempty"`
}
// AutoConfigService orchestrates the full pipeline from user intent to deployed config.
type AutoConfigService struct {
preview *ConfigPreviewService
tasks *TaskService
registry *FeatureRegistry
}
// NewAutoConfigService creates a new AutoConfigService.
func NewAutoConfigService(preview *ConfigPreviewService, tasks *TaskService, registry *FeatureRegistry) *AutoConfigService {
return &AutoConfigService{
preview: preview,
tasks: tasks,
registry: registry,
}
}
// BuildPipeline executes the full auto-config pipeline for a single device:
// 1. Resolve template from feature selection
// 2. If composed, generate the composed template
// 3. Create/update the scene template (profile)
// 4. Create/update recognition units for each video source
// 5. Create/update the device assignment
// 6. Render the final config
// 7. Create a deploy task
//
// If deploy is false, steps 6-7 are skipped (dry-run mode).
func (s *AutoConfigService) BuildPipeline(req AutoConfigRequest, deploy bool) (*AutoConfigResult, error) {
result := &AutoConfigResult{DeviceID: req.DeviceID}
// ---- 1. Resolve template ----
cleanSources := cleanSourceNames(req.SourceNames)
if len(cleanSources) == 0 {
result.Error = "未分配任何视频源"
return result, fmt.Errorf(result.Error)
}
resolveResult, err := s.registry.ResolveTemplate(req.Features)
if err != nil {
result.Error = err.Error()
return result, err
}
templateName := resolveResult.TemplateName
result.Composed = resolveResult.Composed
// ---- 2. Compose template if needed ----
if resolveResult.Composed {
composed, err := s.composeFromFeatureTemplates(req.Features, resolveResult.composePrimary, resolveResult.composeFeatures)
if err != nil {
result.Error = fmt.Sprintf("模板组合失败: %v", err)
return result, fmt.Errorf(result.Error)
}
body, err := marshalConfigJSON(composed)
if err != nil {
result.Error = fmt.Sprintf("序列化组合模板失败: %v", err)
return result, fmt.Errorf(result.Error)
}
if err := s.preview.SaveTemplateAsset(templateName, "自动组合: "+strings.Join(req.Features, "+"), string(body)); err != nil {
result.Error = fmt.Sprintf("保存组合模板失败: %v", err)
return result, fmt.Errorf(result.Error)
}
}
result.TemplateName = templateName
// ---- 3. Ensure profile (scene template) ----
profileName := "auto_" + req.DeviceID
editor := ConfigProfileEditor{
Name: profileName,
PrimaryTemplateName: templateName,
BusinessName: req.DeviceID + " 自动配置",
Description: "管控台一键生成",
}
// ---- 4. Create recognition units for each video source ----
for _, srcName := range cleanSources {
unitName := deriveSafeInstanceName(srcName)
inst := ConfigProfileInstanceEditor{
Name: unitName,
Template: templateName,
VideoSourceRef: srcName,
DisplayName: srcName,
InputBindings: map[string]InputBindingEditor{
"video_input_main": {VideoSourceRef: srcName},
},
OutputBindings: map[string]OutputBindingEditor{
"stream_output_main": {
PublishHLSPath: "./web/hls/" + unitName + "/index.m3u8",
PublishRTSPPort: "8555",
PublishRTSPPath: "/live/" + unitName,
ChannelNo: unitName,
},
},
}
editor.Instances = append(editor.Instances, inst)
}
if err := s.preview.SaveProfileEditor(editor); err != nil {
result.Error = fmt.Sprintf("保存场景模板失败: %v", err)
return result, fmt.Errorf(result.Error)
}
// ---- 4b. Save recognition units via the dedicated API ----
for _, inst := range editor.Instances {
unit := RecognitionUnitAsset{
SceneTemplateName: profileName,
Name: inst.Name,
DisplayName: inst.DisplayName,
VideoSourceRef: inst.VideoSourceRef,
OutputChannel: inst.ChannelNo,
RTSPPort: inst.PublishRTSPPort,
}
ref := recognitionUnitRef(profileName, inst.Name)
if err := s.preview.SaveRecognitionUnit(unit, ref); err != nil {
result.Error = fmt.Sprintf("保存识别单元 %s 失败: %v", inst.Name, err)
return result, fmt.Errorf(result.Error)
}
result.UnitsCreated++
}
// ---- 5. Device assignment ----
unitRefs := make([]string, 0, len(cleanSources))
for _, inst := range editor.Instances {
unitRefs = append(unitRefs, recognitionUnitRef(profileName, inst.Name))
}
assignment := DeviceAssignmentAsset{
DeviceID: req.DeviceID,
ProfileName: profileName,
Description: "管控台自动分配",
RecognitionUnits: unitRefs,
}
if err := s.preview.SaveDeviceAssignment(assignment); err != nil {
result.Error = fmt.Sprintf("保存设备分配失败: %v", err)
return result, fmt.Errorf(result.Error)
}
if !deploy {
preview, err := s.preview.RenderDeviceAssignment(req.DeviceID)
if err != nil {
result.Error = fmt.Sprintf("配置渲染失败: %v", err)
return result, fmt.Errorf(result.Error)
}
result.ConfigSHA256 = preview.Sha256
return result, nil
}
// ---- 6 & 7. Render and deploy ----
preview, err := s.preview.RenderDeviceAssignment(req.DeviceID)
if err != nil {
result.Error = fmt.Sprintf("配置渲染失败: %v", err)
return result, fmt.Errorf(result.Error)
}
result.ConfigSHA256 = preview.Sha256
var configDoc any
if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil {
result.Error = fmt.Sprintf("配置 JSON 无效: %v", err)
return result, fmt.Errorf(result.Error)
}
task, err := s.tasks.CreateTask("config_apply", []string{req.DeviceID}, map[string]any{"config": configDoc})
if err != nil {
result.Error = fmt.Sprintf("创建下发任务失败: %v", err)
return result, fmt.Errorf(result.Error)
}
result.TaskID = task.ID
return result, nil
}
// BuildPipelineBatch executes the pipeline for multiple devices.
func (s *AutoConfigService) BuildPipelineBatch(requests []AutoConfigRequest, deploy bool) ([]AutoConfigResult, error) {
results := make([]AutoConfigResult, 0, len(requests))
var firstErr error
for _, req := range requests {
result, err := s.BuildPipeline(req, deploy)
if result != nil {
results = append(results, *result)
}
if err != nil && firstErr == nil {
firstErr = err
}
}
return results, firstErr
}
// composeFromFeatureTemplates loads templates for each feature and composes them.
func (s *AutoConfigService) composeFromFeatureTemplates(featureKeys []string, primaryName string, allFeatures []string) (map[string]any, error) {
primaryRaw, _, err := s.preview.readAssetJSON("templates", primaryName)
if err != nil {
return nil, fmt.Errorf("load primary template %q: %w", primaryName, err)
}
templates := []map[string]any{primaryRaw}
for _, key := range featureKeys {
if def := s.registry.Feature(key); def != nil {
for _, tplName := range def.Templates {
if tplName == primaryName {
continue
}
raw, _, err := s.preview.readAssetJSON("templates", tplName)
if err != nil {
continue
}
templates = append(templates, raw)
break
}
}
}
if len(templates) <= 1 {
return primaryRaw, nil
}
return ComposeTemplates(templates)
}
// ---- Device feature persistence ----
// GetDeviceFeatures returns the persisted feature keys for a device.
func (s *ConfigPreviewService) GetDeviceFeatures(deviceID string) ([]string, error) {
if s == nil || s.assets == nil {
return nil, nil
}
rec, err := s.assets.GetDeviceFeatures(deviceID)
if err != nil || rec == nil {
return nil, err
}
var features []string
if err := json.Unmarshal([]byte(rec.FeaturesJSON), &features); err != nil {
return nil, nil
}
return features, nil
}
// SaveDeviceFeatures persists the feature keys for a device.
func (s *ConfigPreviewService) SaveDeviceFeatures(deviceID string, features []string) error {
if s == nil || s.assets == nil {
return fmt.Errorf("asset repository is not configured")
}
if len(features) == 0 {
features = []string{}
}
body, err := json.Marshal(features)
if err != nil {
return err
}
return s.assets.SaveDeviceFeatures(deviceID, string(body))
}
func cleanSourceNames(names []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(names))
for _, n := range names {
n = strings.TrimSpace(n)
if n == "" {
continue
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
out = append(out, n)
}
sort.Strings(out)
return out
}
// deriveSafeInstanceName converts a user-facing video source name into a valid
// recognition unit instance name ([A-Za-z0-9_.-]+). If the source name is already
// valid, it's used as-is. Otherwise, a sanitised version is generated.
func deriveSafeInstanceName(srcName string) string {
// If already valid per validateConfigName rules, use as-is.
if safeConfigName.MatchString(srcName) {
return srcName
}
// Build a safe name from the source: keep only ASCII letters, digits, underscores.
var b strings.Builder
for _, r := range srcName {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' {
b.WriteRune(r)
}
}
derived := strings.Trim(b.String(), "-_.")
if derived != "" && safeConfigName.MatchString(derived) {
return derived
}
// Fallback: use a short hash suffix to guarantee uniqueness.
sum := sha256.Sum256([]byte(srcName))
return "src_" + strings.ToLower(hex.EncodeToString(sum[:4]))
}