1825 lines
56 KiB
Go
1825 lines
56 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"3588AdminBackend/internal/models"
|
|
"3588AdminBackend/internal/storage"
|
|
)
|
|
|
|
var legacyBuiltinTemplateAliases = map[string]string{
|
|
"workshop_face_shoe_alarm": "std_workshop_face_recognition_shoe_alarm",
|
|
"std_service_smoke_stream": "std_service_test_stream",
|
|
}
|
|
|
|
type ConfigTemplateAsset struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Origin string `json:"origin"`
|
|
ReadOnly bool `json:"read_only"`
|
|
Description string `json:"description"`
|
|
Source string `json:"source"`
|
|
Slots TemplateSlotGroup `json:"slots"`
|
|
NodeCount int `json:"node_count"`
|
|
EdgeCount int `json:"edge_count"`
|
|
MinIOEndpoint string `json:"minio_endpoint"`
|
|
MinIOBucket string `json:"minio_bucket"`
|
|
ExternalGetTokenURL string `json:"external_get_token_url"`
|
|
ExternalPutMessageURL string `json:"external_put_message_url"`
|
|
TenantCode string `json:"tenant_code"`
|
|
AdvancedParams map[string]any `json:"advanced_params"`
|
|
Raw map[string]any `json:"raw"`
|
|
}
|
|
|
|
type ConfigProfileAsset struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Description string `json:"description"`
|
|
BusinessName string `json:"business_name"`
|
|
QueueSize int `json:"queue_size"`
|
|
QueueStrategy string `json:"queue_strategy"`
|
|
Instances []ConfigProfileInstanceAsset `json:"instances"`
|
|
Raw map[string]any `json:"raw"`
|
|
}
|
|
|
|
type ConfigProfileInstanceAsset struct {
|
|
Name string `json:"name"`
|
|
Template string `json:"template"`
|
|
VideoSourceRef string `json:"video_source_ref"`
|
|
DisplayName string `json:"display_name"`
|
|
DeviceCode string `json:"device_code"`
|
|
SiteName string `json:"site_name"`
|
|
PublishHLSPath string `json:"publish_hls_path"`
|
|
PublishRTSPPort string `json:"publish_rtsp_port"`
|
|
PublishRTSPPath string `json:"publish_rtsp_path"`
|
|
ChannelNo string `json:"channel_no"`
|
|
AdvancedParams map[string]any `json:"advanced_params"`
|
|
}
|
|
|
|
type ConfigOverlayAsset struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Description string `json:"description"`
|
|
OverrideTargets []string `json:"override_targets"`
|
|
OverrideTargetNum int `json:"override_target_num"`
|
|
Raw map[string]any `json:"raw"`
|
|
}
|
|
|
|
type ConfigIntegrationServiceAsset struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Type string `json:"type"`
|
|
TypeLabel string `json:"type_label"`
|
|
Description string `json:"description"`
|
|
Enabled bool `json:"enabled"`
|
|
AddressSummary string `json:"address_summary"`
|
|
RefCount int `json:"ref_count"`
|
|
ObjectStorage *ObjectStorageConfig `json:"object_storage,omitempty"`
|
|
TokenService *TokenServiceConfig `json:"token_service,omitempty"`
|
|
AlarmService *AlarmServiceConfig `json:"alarm_service,omitempty"`
|
|
Raw map[string]any `json:"raw"`
|
|
}
|
|
|
|
type ObjectStorageConfig struct {
|
|
Endpoint string `json:"endpoint"`
|
|
Bucket string `json:"bucket"`
|
|
AccessKey string `json:"access_key"`
|
|
SecretKey string `json:"secret_key"`
|
|
}
|
|
|
|
type TokenServiceConfig struct {
|
|
GetTokenURL string `json:"get_token_url"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
TenantCode string `json:"tenant_code"`
|
|
}
|
|
|
|
type AlarmServiceConfig struct {
|
|
PutMessageURL string `json:"put_message_url"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
TenantCode string `json:"tenant_code"`
|
|
}
|
|
|
|
type ConfigVideoSourceAsset struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
SourceType string `json:"source_type"`
|
|
SourceTypeLabel string `json:"source_type_label"`
|
|
Area string `json:"area"`
|
|
Description string `json:"description"`
|
|
RefCount int `json:"ref_count"`
|
|
SourceSummary string `json:"source_summary"`
|
|
Config VideoSourceConfig `json:"config"`
|
|
Raw map[string]any `json:"raw"`
|
|
}
|
|
|
|
type RecognitionUnitAsset struct {
|
|
Ref string `json:"ref"`
|
|
SceneTemplateName string `json:"scene_template_name"`
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"display_name"`
|
|
SiteName string `json:"site_name"`
|
|
VideoSourceRef string `json:"video_source_ref"`
|
|
OutputChannel string `json:"output_channel"`
|
|
RTSPPort string `json:"rtsp_port"`
|
|
Description string `json:"description"`
|
|
AdvancedParams map[string]any `json:"advanced_params,omitempty"`
|
|
}
|
|
|
|
type DeviceAssignmentAsset struct {
|
|
DeviceID string `json:"device_id"`
|
|
ProfileName string `json:"profile_name"`
|
|
Description string `json:"description"`
|
|
RecognitionUnits []string `json:"recognition_units"`
|
|
RecognitionCount int `json:"recognition_count"`
|
|
}
|
|
|
|
type DeviceAssignmentBoardStats struct {
|
|
TotalUnits int `json:"total_units"`
|
|
TotalDevices int `json:"total_devices"`
|
|
AssignedUnits int `json:"assigned_units"`
|
|
UnassignedUnits int `json:"unassigned_units"`
|
|
AverageLoad float64 `json:"average_load"`
|
|
OverloadedDevices int `json:"overloaded_devices"`
|
|
}
|
|
|
|
type DeviceAssignmentBoardUnit struct {
|
|
Ref string `json:"ref"`
|
|
SceneTemplateName string `json:"scene_template_name"`
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"display_name"`
|
|
VideoSourceRef string `json:"video_source_ref"`
|
|
OutputChannel string `json:"output_channel"`
|
|
}
|
|
|
|
type DeviceAssignmentBoardCard struct {
|
|
DeviceID string `json:"device_id"`
|
|
DeviceName string `json:"device_name"`
|
|
ProfileName string `json:"profile_name"`
|
|
AssignedCount int `json:"assigned_count"`
|
|
MaxUnits int `json:"max_units"`
|
|
Status string `json:"status"`
|
|
Units []DeviceAssignmentBoardUnit `json:"units"`
|
|
}
|
|
|
|
type DeviceAssignmentBoard struct {
|
|
MaxUnitsPerDevice int `json:"max_units_per_device"`
|
|
Stats DeviceAssignmentBoardStats `json:"stats"`
|
|
Cards []DeviceAssignmentBoardCard `json:"cards"`
|
|
Unassigned []DeviceAssignmentBoardUnit `json:"unassigned"`
|
|
}
|
|
|
|
type VideoSourceConfig struct {
|
|
URL string `json:"url"`
|
|
Resolution string `json:"resolution"`
|
|
FrameSize string `json:"frame_size"`
|
|
FPS string `json:"fps"`
|
|
VideoFormat string `json:"video_format"`
|
|
FocalLength string `json:"focal_length"`
|
|
MountHeight string `json:"mount_height"`
|
|
MountAngle string `json:"mount_angle"`
|
|
}
|
|
|
|
func (s *ConfigPreviewService) ListTemplateAssets() ([]ConfigTemplateAsset, error) {
|
|
items := make([]ConfigTemplateAsset, 0)
|
|
seen := map[string]bool{}
|
|
|
|
if s != nil && s.assets != nil {
|
|
records, err := s.assets.ListTemplates()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, record := range records {
|
|
name := strings.TrimSpace(record.Name)
|
|
if name == "" || seen[name] || legacyBuiltinTemplateAliases[name] != "" {
|
|
continue
|
|
}
|
|
readOnly := isStandardTemplateName(name)
|
|
origin := "user"
|
|
if readOnly {
|
|
origin = "standard"
|
|
}
|
|
item, err := s.templateAssetFromRecord(record, origin, readOnly)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
items = append(items, *item)
|
|
seen[item.Name] = true
|
|
}
|
|
}
|
|
|
|
sort.Slice(items, func(i, j int) bool {
|
|
if items[i].ReadOnly != items[j].ReadOnly {
|
|
return items[i].ReadOnly
|
|
}
|
|
return items[i].Name < items[j].Name
|
|
})
|
|
return items, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) GetTemplateAsset(name string) (*ConfigTemplateAsset, error) {
|
|
name = canonicalTemplateAssetName(name)
|
|
if err := validateConfigName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
if s != nil && s.assets != nil {
|
|
record, err := s.assets.GetTemplate(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if record != nil {
|
|
readOnly := isStandardTemplateName(name)
|
|
origin := "user"
|
|
if readOnly {
|
|
origin = "standard"
|
|
}
|
|
return s.templateAssetFromRecord(*record, origin, readOnly)
|
|
}
|
|
}
|
|
return nil, os.ErrNotExist
|
|
}
|
|
|
|
func (s *ConfigPreviewService) SaveTemplateAsset(name string, description string, bodyJSON string) error {
|
|
if s == nil || s.assets == nil {
|
|
return fmt.Errorf("asset repository is not configured")
|
|
}
|
|
name = canonicalTemplateAssetName(name)
|
|
if err := validateConfigName(name); err != nil {
|
|
return err
|
|
}
|
|
if isStandardTemplateName(name) {
|
|
return fmt.Errorf("standard template %q is read-only; please copy it before editing", name)
|
|
}
|
|
return s.assets.SaveTemplate(name, description, bodyJSON)
|
|
}
|
|
|
|
func (s *ConfigPreviewService) RenameTemplateAsset(oldName string, newName string, description string, bodyJSON string) error {
|
|
if s == nil || s.assets == nil {
|
|
return fmt.Errorf("asset repository is not configured")
|
|
}
|
|
oldName = canonicalTemplateAssetName(oldName)
|
|
newName = canonicalTemplateAssetName(newName)
|
|
if err := validateConfigName(oldName); err != nil {
|
|
return err
|
|
}
|
|
if err := validateConfigName(newName); err != nil {
|
|
return err
|
|
}
|
|
if isStandardTemplateName(oldName) {
|
|
return fmt.Errorf("standard template %q is read-only; please copy it before editing", oldName)
|
|
}
|
|
if oldName != newName && isStandardTemplateName(newName) {
|
|
return fmt.Errorf("standard template name %q is reserved", newName)
|
|
}
|
|
return s.assets.RenameTemplate(oldName, newName, description, bodyJSON)
|
|
}
|
|
|
|
func (s *ConfigPreviewService) DeleteTemplateAsset(name string) error {
|
|
if s == nil || s.assets == nil {
|
|
return fmt.Errorf("asset repository is not configured")
|
|
}
|
|
name = canonicalTemplateAssetName(name)
|
|
if err := validateConfigName(name); err != nil {
|
|
return err
|
|
}
|
|
if isStandardTemplateName(name) {
|
|
return fmt.Errorf("standard template %q is read-only and cannot be deleted", name)
|
|
}
|
|
refs, err := s.profileNamesReferencingTemplate(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(refs) > 0 {
|
|
return fmt.Errorf("template %q is used by business configs: %s", name, strings.Join(refs, ", "))
|
|
}
|
|
return s.assets.DeleteTemplate(name)
|
|
}
|
|
|
|
func (s *ConfigPreviewService) ListProfileAssets() ([]ConfigProfileAsset, error) {
|
|
sources, err := s.ListSources()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]ConfigProfileAsset, 0, len(sources.Profiles))
|
|
for _, source := range sources.Profiles {
|
|
item, err := s.GetProfileAsset(source.Name)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
items = append(items, *item)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) profileNamesReferencingTemplate(templateName string) ([]string, error) {
|
|
if s == nil || s.assets == nil {
|
|
return nil, nil
|
|
}
|
|
records, err := s.assets.ListProfiles()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
refs := make([]string, 0)
|
|
for _, record := range records {
|
|
if strings.TrimSpace(record.TemplateName) == templateName || strings.Contains(record.BodyJSON, `"`+"template"+`": "`+templateName+`"`) {
|
|
refs = append(refs, record.Name)
|
|
}
|
|
}
|
|
sort.Strings(refs)
|
|
return refs, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset, error) {
|
|
raw, path, err := s.readAssetJSON("profiles", name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
queueMap, _ := raw["queue"].(map[string]any)
|
|
units, err := s.ListRecognitionUnits()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
instances := make([]ConfigProfileInstanceAsset, 0)
|
|
for _, unit := range units {
|
|
if unit.SceneTemplateName != name {
|
|
continue
|
|
}
|
|
channel := firstString(unit.OutputChannel, unit.Name)
|
|
instances = append(instances, ConfigProfileInstanceAsset{
|
|
Name: unit.Name,
|
|
Template: stringValue(raw["primary_template_name"]),
|
|
VideoSourceRef: unit.VideoSourceRef,
|
|
DisplayName: unit.DisplayName,
|
|
SiteName: unit.SiteName,
|
|
PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8",
|
|
PublishRTSPPort: unit.RTSPPort,
|
|
PublishRTSPPath: "/live/" + channel,
|
|
ChannelNo: channel,
|
|
AdvancedParams: cloneMap(unit.AdvancedParams),
|
|
})
|
|
}
|
|
return &ConfigProfileAsset{
|
|
Name: firstString(raw["name"], name),
|
|
Path: path,
|
|
Description: stringValue(raw["description"]),
|
|
BusinessName: stringValue(raw["business_name"]),
|
|
QueueSize: intValue(queueMap["size"]),
|
|
QueueStrategy: stringValue(queueMap["strategy"]),
|
|
Instances: instances,
|
|
Raw: raw,
|
|
}, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) DeleteProfileAsset(name string) error {
|
|
if s == nil || s.assets == nil {
|
|
return fmt.Errorf("asset repository is not configured")
|
|
}
|
|
if err := validateConfigName(name); err != nil {
|
|
return err
|
|
}
|
|
units, err := s.ListRecognitionUnits()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, unit := range units {
|
|
if unit.SceneTemplateName == name {
|
|
return fmt.Errorf("场景模板 %q 下仍有识别单元,无法删除", name)
|
|
}
|
|
}
|
|
return s.assets.DeleteProfile(name)
|
|
}
|
|
|
|
func recognitionUnitRef(profileName string, unitName string) string {
|
|
return strings.TrimSpace(profileName) + "::" + strings.TrimSpace(unitName)
|
|
}
|
|
|
|
func parseRecognitionUnitRef(ref string) (string, string, error) {
|
|
parts := strings.SplitN(strings.TrimSpace(ref), "::", 2)
|
|
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
|
|
return "", "", fmt.Errorf("invalid recognition unit ref: %s", ref)
|
|
}
|
|
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) ListRecognitionUnits() ([]RecognitionUnitAsset, error) {
|
|
if s == nil || s.assets == nil {
|
|
return nil, fmt.Errorf("asset repository is not configured")
|
|
}
|
|
records, err := s.assets.ListRecognitionUnits()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]RecognitionUnitAsset, 0, len(records))
|
|
for _, record := range records {
|
|
item, err := recognitionUnitFromRecord(record)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
items = append(items, *item)
|
|
}
|
|
sort.Slice(items, func(i, j int) bool {
|
|
if items[i].SceneTemplateName != items[j].SceneTemplateName {
|
|
return items[i].SceneTemplateName < items[j].SceneTemplateName
|
|
}
|
|
return items[i].Name < items[j].Name
|
|
})
|
|
return items, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) GetRecognitionUnit(ref string) (*RecognitionUnitAsset, error) {
|
|
sceneTemplateName, unitName, err := parseRecognitionUnitRef(ref)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
record, err := s.assets.GetRecognitionUnit(sceneTemplateName, unitName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if record == nil {
|
|
return nil, os.ErrNotExist
|
|
}
|
|
return recognitionUnitFromRecord(*record)
|
|
}
|
|
|
|
func (s *ConfigPreviewService) SaveRecognitionUnit(unit RecognitionUnitAsset, originalRef string) error {
|
|
if s == nil || s.assets == nil {
|
|
return fmt.Errorf("asset repository is not configured")
|
|
}
|
|
sceneTemplate := strings.TrimSpace(unit.SceneTemplateName)
|
|
if err := validateConfigName(sceneTemplate); err != nil {
|
|
return fmt.Errorf("invalid scene template: %w", err)
|
|
}
|
|
unitName := strings.TrimSpace(unit.Name)
|
|
if err := validateConfigName(unitName); err != nil {
|
|
return fmt.Errorf("invalid recognition unit name: %w", err)
|
|
}
|
|
if strings.TrimSpace(unit.VideoSourceRef) == "" {
|
|
return fmt.Errorf("video source is required")
|
|
}
|
|
originalTemplate := sceneTemplate
|
|
originalName := unitName
|
|
if strings.TrimSpace(originalRef) != "" {
|
|
if p, n, err := parseRecognitionUnitRef(originalRef); err == nil {
|
|
originalTemplate = p
|
|
originalName = n
|
|
}
|
|
}
|
|
if _, err := s.GetProfileEditor(sceneTemplate); err != nil {
|
|
return err
|
|
}
|
|
if sceneTemplate != originalTemplate {
|
|
if _, err := s.GetProfileEditor(originalTemplate); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if sceneTemplate != originalTemplate || unitName != originalName {
|
|
existing, err := s.assets.GetRecognitionUnit(sceneTemplate, unitName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existing != nil {
|
|
return fmt.Errorf("duplicate recognition unit name: %s", unitName)
|
|
}
|
|
}
|
|
if err := s.assets.SaveRecognitionUnit(recognitionUnitToRecord(unit)); err != nil {
|
|
return err
|
|
}
|
|
if sceneTemplate != originalTemplate || unitName != originalName {
|
|
if err := s.assets.DeleteRecognitionUnit(originalTemplate, originalName); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) loadRecognitionUnitEditors(sceneTemplateName string, templateName string) ([]ConfigProfileInstanceEditor, string, string, error) {
|
|
units, err := s.ListRecognitionUnits()
|
|
if err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
instances := make([]ConfigProfileInstanceEditor, 0)
|
|
siteName := ""
|
|
deviceCode := ""
|
|
for _, unit := range units {
|
|
if unit.SceneTemplateName != sceneTemplateName {
|
|
continue
|
|
}
|
|
instances = append(instances, recognitionUnitToInstanceEditor(unit, templateName))
|
|
if siteName == "" && strings.TrimSpace(unit.SiteName) != "" {
|
|
siteName = strings.TrimSpace(unit.SiteName)
|
|
}
|
|
if deviceCode == "" {
|
|
if code := strings.TrimSpace(stringAny(unit.AdvancedParams["device_code"])); code != "" {
|
|
deviceCode = code
|
|
}
|
|
}
|
|
}
|
|
return instances, siteName, deviceCode, nil
|
|
}
|
|
|
|
func recognitionUnitToInstanceEditor(unit RecognitionUnitAsset, templateName string) ConfigProfileInstanceEditor {
|
|
channel := strings.TrimSpace(firstString(unit.OutputChannel, unit.Name))
|
|
rtspPort := strings.TrimSpace(firstString(unit.RTSPPort, "8555"))
|
|
return ConfigProfileInstanceEditor{
|
|
Name: strings.TrimSpace(unit.Name),
|
|
Template: templateName,
|
|
VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef),
|
|
DisplayName: strings.TrimSpace(unit.DisplayName),
|
|
SiteName: strings.TrimSpace(unit.SiteName),
|
|
ChannelNo: channel,
|
|
PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8",
|
|
PublishRTSPPort: rtspPort,
|
|
PublishRTSPPath: "/live/" + channel,
|
|
InputBindings: map[string]InputBindingEditor{
|
|
"video_input_main": {VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef)},
|
|
},
|
|
OutputBindings: map[string]OutputBindingEditor{
|
|
"stream_output_main": {
|
|
PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8",
|
|
PublishRTSPPort: rtspPort,
|
|
PublishRTSPPath: "/live/" + channel,
|
|
ChannelNo: channel,
|
|
},
|
|
},
|
|
AdvancedParams: cloneMap(unit.AdvancedParams),
|
|
}
|
|
}
|
|
|
|
func recognitionUnitToRecord(unit RecognitionUnitAsset) storage.RecognitionUnitRecord {
|
|
channel := strings.TrimSpace(firstString(unit.OutputChannel, unit.Name))
|
|
if channel == "" {
|
|
channel = strings.TrimSpace(unit.Name)
|
|
}
|
|
rtspPort := strings.TrimSpace(firstString(unit.RTSPPort, "8555"))
|
|
raw := map[string]any{
|
|
"name": strings.TrimSpace(unit.Name),
|
|
"scene_meta": map[string]any{
|
|
"display_name": strings.TrimSpace(unit.DisplayName),
|
|
"site_name": strings.TrimSpace(unit.SiteName),
|
|
},
|
|
"input_bindings": map[string]any{
|
|
"video_input_main": map[string]any{
|
|
"video_source_ref": strings.TrimSpace(unit.VideoSourceRef),
|
|
},
|
|
},
|
|
"output_bindings": map[string]any{
|
|
"stream_output_main": map[string]any{
|
|
"publish_hls_path": "./web/hls/" + unit.Name + "/index.m3u8",
|
|
"publish_rtsp_port": rtspPort,
|
|
"publish_rtsp_path": "/live/" + channel,
|
|
"channel_no": channel,
|
|
},
|
|
},
|
|
}
|
|
if params := cloneMap(unit.AdvancedParams); len(params) > 0 {
|
|
raw["params"] = params
|
|
}
|
|
body, _ := marshalConfigJSON(raw)
|
|
return storage.RecognitionUnitRecord{
|
|
SceneTemplateName: strings.TrimSpace(unit.SceneTemplateName),
|
|
Name: strings.TrimSpace(unit.Name),
|
|
DisplayName: strings.TrimSpace(unit.DisplayName),
|
|
SiteName: strings.TrimSpace(unit.SiteName),
|
|
VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef),
|
|
OutputChannel: channel,
|
|
RTSPPort: rtspPort,
|
|
Description: strings.TrimSpace(unit.Description),
|
|
BodyJSON: string(body),
|
|
}
|
|
}
|
|
|
|
func recognitionUnitFromRecord(record storage.RecognitionUnitRecord) (*RecognitionUnitAsset, error) {
|
|
raw := map[string]any{}
|
|
if strings.TrimSpace(record.BodyJSON) != "" {
|
|
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
sceneMeta, _ := raw["scene_meta"].(map[string]any)
|
|
params, _ := raw["params"].(map[string]any)
|
|
advanced := cloneMap(params)
|
|
if deviceCode := strings.TrimSpace(stringAny(sceneMeta["device_code"])); deviceCode != "" {
|
|
advanced["device_code"] = deviceCode
|
|
}
|
|
return &RecognitionUnitAsset{
|
|
Ref: recognitionUnitRef(record.SceneTemplateName, record.Name),
|
|
SceneTemplateName: strings.TrimSpace(record.SceneTemplateName),
|
|
Name: strings.TrimSpace(firstString(stringAny(raw["name"]), record.Name)),
|
|
DisplayName: strings.TrimSpace(firstString(stringAny(sceneMeta["display_name"]), record.DisplayName)),
|
|
SiteName: strings.TrimSpace(firstString(stringAny(sceneMeta["site_name"]), record.SiteName)),
|
|
VideoSourceRef: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "input_bindings", "video_input_main", "video_source_ref"), record.VideoSourceRef)),
|
|
OutputChannel: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "output_bindings", "stream_output_main", "channel_no"), record.OutputChannel)),
|
|
RTSPPort: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "output_bindings", "stream_output_main", "publish_rtsp_port"), record.RTSPPort)),
|
|
Description: strings.TrimSpace(record.Description),
|
|
AdvancedParams: advanced,
|
|
}, nil
|
|
}
|
|
|
|
func bindingStringFromAnyMap(raw map[string]any, bindingKey string, slot string, field string) string {
|
|
bindings, _ := raw[bindingKey].(map[string]any)
|
|
if bindings == nil {
|
|
return ""
|
|
}
|
|
return bindingStringFromBindings(bindings, slot, field)
|
|
}
|
|
|
|
func bindingStringFromBindings(bindings map[string]any, slot string, field string) string {
|
|
entry, _ := bindings[slot].(map[string]any)
|
|
return strings.TrimSpace(stringAny(entry[field]))
|
|
}
|
|
|
|
func (e *ConfigProfileEditor) RawTemplateName() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
if strings.TrimSpace(e.PrimaryTemplateName) != "" {
|
|
return strings.TrimSpace(e.PrimaryTemplateName)
|
|
}
|
|
return firstProfileTemplate(e.Instances)
|
|
}
|
|
|
|
func (s *ConfigPreviewService) DeleteRecognitionUnit(ref string) error {
|
|
sceneTemplateName, unitName, err := parseRecognitionUnitRef(ref)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if refs, err := s.deviceAssignmentsReferencingRecognitionUnit(ref); err != nil {
|
|
return err
|
|
} else if len(refs) > 0 {
|
|
return fmt.Errorf("识别单元 %q 已被设备分配引用:%s", unitName, strings.Join(refs, ", "))
|
|
}
|
|
item, err := s.assets.GetRecognitionUnit(sceneTemplateName, unitName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if item == nil {
|
|
return os.ErrNotExist
|
|
}
|
|
return s.assets.DeleteRecognitionUnit(sceneTemplateName, unitName)
|
|
}
|
|
|
|
func (s *ConfigPreviewService) ListOverlayAssets() ([]ConfigOverlayAsset, error) {
|
|
sources, err := s.ListSources()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]ConfigOverlayAsset, 0, len(sources.Overlays))
|
|
for _, source := range sources.Overlays {
|
|
item, err := s.GetOverlayAsset(source.Name)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
items = append(items, *item)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) GetOverlayAsset(name string) (*ConfigOverlayAsset, error) {
|
|
raw, path, err := s.readAssetJSON("overlays", name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
targets := make([]string, 0)
|
|
if overrides, ok := raw["instance_overrides"].(map[string]any); ok {
|
|
for key := range overrides {
|
|
targets = append(targets, key)
|
|
}
|
|
sort.Strings(targets)
|
|
}
|
|
return &ConfigOverlayAsset{
|
|
Name: name,
|
|
Path: path,
|
|
Description: stringValue(raw["description"]),
|
|
OverrideTargets: targets,
|
|
OverrideTargetNum: len(targets),
|
|
Raw: raw,
|
|
}, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) SaveOverlayAsset(asset ConfigOverlayAsset, raw map[string]any) error {
|
|
if s == nil || s.assets == nil {
|
|
return fmt.Errorf("asset repository is not configured")
|
|
}
|
|
name := strings.TrimSpace(asset.Name)
|
|
if err := validateConfigName(name); err != nil {
|
|
return err
|
|
}
|
|
if raw == nil {
|
|
raw = map[string]any{}
|
|
}
|
|
raw["name"] = name
|
|
raw["description"] = strings.TrimSpace(asset.Description)
|
|
body, err := marshalConfigJSON(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.assets.SaveOverlay(name, strings.TrimSpace(asset.Description), string(body))
|
|
}
|
|
|
|
func (s *ConfigPreviewService) DeleteOverlayAsset(name string) error {
|
|
if s == nil || s.assets == nil {
|
|
return fmt.Errorf("asset repository is not configured")
|
|
}
|
|
if err := validateConfigName(name); err != nil {
|
|
return err
|
|
}
|
|
refs, err := s.profileNamesReferencingOverlay(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(refs) > 0 {
|
|
return fmt.Errorf("调试参数 %q 已被场景模板引用:%s", name, strings.Join(refs, ", "))
|
|
}
|
|
return s.assets.DeleteOverlay(name)
|
|
}
|
|
|
|
func (s *ConfigPreviewService) ListIntegrationServices() ([]ConfigIntegrationServiceAsset, error) {
|
|
if s == nil || s.assets == nil {
|
|
return nil, nil
|
|
}
|
|
records, err := s.assets.ListIntegrationServices()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]ConfigIntegrationServiceAsset, 0, len(records))
|
|
for _, record := range records {
|
|
item, err := integrationServiceAssetFromRecord(record)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if refs, err := s.profileNamesReferencingIntegrationService(item.Name); err == nil {
|
|
item.RefCount = len(refs)
|
|
}
|
|
items = append(items, *item)
|
|
}
|
|
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
|
|
return items, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) ListVideoSources() ([]ConfigVideoSourceAsset, error) {
|
|
if s == nil || s.assets == nil {
|
|
return nil, nil
|
|
}
|
|
records, err := s.assets.ListVideoSources()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]ConfigVideoSourceAsset, 0, len(records))
|
|
for _, record := range records {
|
|
item, err := videoSourceAssetFromRecord(record)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if refs, err := s.profileNamesReferencingVideoSource(item.Name); err == nil {
|
|
item.RefCount = len(refs)
|
|
}
|
|
items = append(items, *item)
|
|
}
|
|
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
|
|
return items, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) GetVideoSource(name string) (*ConfigVideoSourceAsset, error) {
|
|
if s == nil || s.assets == nil {
|
|
return nil, fmt.Errorf("基础配置仓库未初始化")
|
|
}
|
|
if err := validateVideoSourceName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
record, err := s.assets.GetVideoSource(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if record == nil {
|
|
return nil, os.ErrNotExist
|
|
}
|
|
item, err := videoSourceAssetFromRecord(*record)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if refs, err := s.profileNamesReferencingVideoSource(item.Name); err == nil {
|
|
item.RefCount = len(refs)
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) SaveVideoSourceAsset(asset ConfigVideoSourceAsset) error {
|
|
if s == nil || s.assets == nil {
|
|
return fmt.Errorf("基础配置仓库未初始化")
|
|
}
|
|
name := strings.TrimSpace(asset.Name)
|
|
if err := validateVideoSourceName(name); err != nil {
|
|
return fmt.Errorf("视频源名称不合法:%w", err)
|
|
}
|
|
sourceType := strings.TrimSpace(asset.SourceType)
|
|
if sourceType == "" {
|
|
return fmt.Errorf("视频源类型不能为空")
|
|
}
|
|
urlValue := strings.TrimSpace(asset.Config.URL)
|
|
if urlValue == "" {
|
|
return fmt.Errorf("视频源地址不能为空")
|
|
}
|
|
raw := map[string]any{
|
|
"name": name,
|
|
"source_type": sourceType,
|
|
"area": strings.TrimSpace(asset.Area),
|
|
"description": strings.TrimSpace(asset.Description),
|
|
"config": map[string]any{},
|
|
}
|
|
configMap := raw["config"].(map[string]any)
|
|
setAnyString(configMap, "url", asset.Config.URL)
|
|
setAnyString(configMap, "resolution", asset.Config.Resolution)
|
|
setAnyString(configMap, "frame_size", asset.Config.FrameSize)
|
|
setAnyString(configMap, "fps", asset.Config.FPS)
|
|
setAnyString(configMap, "video_format", asset.Config.VideoFormat)
|
|
setAnyString(configMap, "focal_length", asset.Config.FocalLength)
|
|
setAnyString(configMap, "mount_height", asset.Config.MountHeight)
|
|
setAnyString(configMap, "mount_angle", asset.Config.MountAngle)
|
|
body, err := marshalConfigJSON(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.assets.SaveVideoSource(name, sourceType, strings.TrimSpace(asset.Area), strings.TrimSpace(asset.Description), string(body))
|
|
}
|
|
|
|
func (s *ConfigPreviewService) DeleteVideoSource(name string) error {
|
|
if s == nil || s.assets == nil {
|
|
return fmt.Errorf("基础配置仓库未初始化")
|
|
}
|
|
if err := validateVideoSourceName(name); err != nil {
|
|
return err
|
|
}
|
|
refs, err := s.profileNamesReferencingVideoSource(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(refs) > 0 {
|
|
return fmt.Errorf("视频源 %q 已被场景模板引用:%s", name, strings.Join(refs, ", "))
|
|
}
|
|
return s.assets.DeleteVideoSource(name)
|
|
}
|
|
|
|
func (s *ConfigPreviewService) GetIntegrationService(name string) (*ConfigIntegrationServiceAsset, error) {
|
|
if s == nil || s.assets == nil {
|
|
return nil, fmt.Errorf("asset repository is not configured")
|
|
}
|
|
if err := validateConfigName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
record, err := s.assets.GetIntegrationService(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if record == nil {
|
|
return nil, os.ErrNotExist
|
|
}
|
|
item, err := integrationServiceAssetFromRecord(*record)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if refs, err := s.profileNamesReferencingIntegrationService(item.Name); err == nil {
|
|
item.RefCount = len(refs)
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) DeleteIntegrationService(name string) error {
|
|
if s == nil || s.assets == nil {
|
|
return fmt.Errorf("asset repository is not configured")
|
|
}
|
|
if err := validateConfigName(name); err != nil {
|
|
return err
|
|
}
|
|
refs, err := s.profileNamesReferencingIntegrationService(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(refs) > 0 {
|
|
return fmt.Errorf("third-party service %q is used by scene configs: %s", name, strings.Join(refs, ", "))
|
|
}
|
|
return s.assets.DeleteIntegrationService(name)
|
|
}
|
|
|
|
func (s *ConfigPreviewService) ListDeviceAssignments() ([]DeviceAssignmentAsset, error) {
|
|
if s == nil || s.assets == nil {
|
|
return nil, nil
|
|
}
|
|
records, err := s.assets.ListDeviceAssignments()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]DeviceAssignmentAsset, 0, len(records))
|
|
for _, record := range records {
|
|
item, err := deviceAssignmentFromRecord(record)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
items = append(items, *item)
|
|
}
|
|
sort.Slice(items, func(i, j int) bool { return items[i].DeviceID < items[j].DeviceID })
|
|
return items, nil
|
|
}
|
|
|
|
func DefaultMaxUnitsPerDevice() int {
|
|
return 4
|
|
}
|
|
|
|
func (s *ConfigPreviewService) BuildDeviceAssignmentBoard(devices []*models.Device, maxUnitsPerDevice int) (*DeviceAssignmentBoard, error) {
|
|
assignments, err := s.ListDeviceAssignments()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
units, err := s.ListRecognitionUnits()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return BuildDeviceAssignmentBoardData(devices, assignments, units, maxUnitsPerDevice), nil
|
|
}
|
|
|
|
func BuildAutoDeviceAssignments(devices []*models.Device, units []RecognitionUnitAsset, maxUnitsPerDevice int) []DeviceAssignmentAsset {
|
|
maxUnitsPerDevice = normalizeMaxUnitsPerDevice(maxUnitsPerDevice)
|
|
type card struct {
|
|
deviceID string
|
|
deviceName string
|
|
profile string
|
|
refs []string
|
|
}
|
|
cards := make([]*card, 0, len(devices))
|
|
for _, dev := range devices {
|
|
if dev == nil || strings.TrimSpace(dev.DeviceID) == "" {
|
|
continue
|
|
}
|
|
cards = append(cards, &card{deviceID: strings.TrimSpace(dev.DeviceID), deviceName: dev.DisplayName()})
|
|
}
|
|
sort.Slice(cards, func(i, j int) bool {
|
|
return cards[i].deviceID < cards[j].deviceID
|
|
})
|
|
sortedUnits := append([]RecognitionUnitAsset(nil), units...)
|
|
sort.Slice(sortedUnits, func(i, j int) bool {
|
|
if sortedUnits[i].SceneTemplateName != sortedUnits[j].SceneTemplateName {
|
|
return sortedUnits[i].SceneTemplateName < sortedUnits[j].SceneTemplateName
|
|
}
|
|
return sortedUnits[i].Name < sortedUnits[j].Name
|
|
})
|
|
for _, unit := range sortedUnits {
|
|
var target *card
|
|
for _, candidate := range cards {
|
|
if candidate.profile != "" && candidate.profile != unit.SceneTemplateName {
|
|
continue
|
|
}
|
|
if len(candidate.refs) >= maxUnitsPerDevice {
|
|
continue
|
|
}
|
|
if target == nil || len(candidate.refs) < len(target.refs) {
|
|
target = candidate
|
|
}
|
|
}
|
|
if target == nil {
|
|
continue
|
|
}
|
|
if target.profile == "" {
|
|
target.profile = unit.SceneTemplateName
|
|
}
|
|
target.refs = append(target.refs, unit.Ref)
|
|
}
|
|
out := make([]DeviceAssignmentAsset, 0, len(cards))
|
|
for _, card := range cards {
|
|
if len(card.refs) == 0 {
|
|
continue
|
|
}
|
|
out = append(out, DeviceAssignmentAsset{
|
|
DeviceID: card.deviceID,
|
|
ProfileName: card.profile,
|
|
RecognitionUnits: append([]string(nil), card.refs...),
|
|
RecognitionCount: len(card.refs),
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].DeviceID < out[j].DeviceID })
|
|
return out
|
|
}
|
|
|
|
func BuildDeviceAssignmentBoardData(devices []*models.Device, assignments []DeviceAssignmentAsset, units []RecognitionUnitAsset, maxUnitsPerDevice int) *DeviceAssignmentBoard {
|
|
maxUnitsPerDevice = normalizeMaxUnitsPerDevice(maxUnitsPerDevice)
|
|
unitMap := make(map[string]RecognitionUnitAsset, len(units))
|
|
for _, unit := range units {
|
|
unitMap[unit.Ref] = unit
|
|
}
|
|
deviceByID := map[string]*models.Device{}
|
|
for _, dev := range devices {
|
|
if dev == nil || strings.TrimSpace(dev.DeviceID) == "" {
|
|
continue
|
|
}
|
|
deviceByID[strings.TrimSpace(dev.DeviceID)] = dev
|
|
}
|
|
assignmentByDevice := map[string]DeviceAssignmentAsset{}
|
|
for _, assignment := range assignments {
|
|
assignmentByDevice[strings.TrimSpace(assignment.DeviceID)] = assignment
|
|
}
|
|
deviceIDs := make([]string, 0, len(deviceByID)+len(assignmentByDevice))
|
|
seenDeviceIDs := map[string]struct{}{}
|
|
for deviceID := range deviceByID {
|
|
seenDeviceIDs[deviceID] = struct{}{}
|
|
deviceIDs = append(deviceIDs, deviceID)
|
|
}
|
|
for deviceID := range assignmentByDevice {
|
|
if _, ok := seenDeviceIDs[deviceID]; ok {
|
|
continue
|
|
}
|
|
seenDeviceIDs[deviceID] = struct{}{}
|
|
deviceIDs = append(deviceIDs, deviceID)
|
|
}
|
|
sort.Strings(deviceIDs)
|
|
|
|
assignedRefs := map[string]struct{}{}
|
|
cards := make([]DeviceAssignmentBoardCard, 0, len(deviceIDs))
|
|
for _, deviceID := range deviceIDs {
|
|
assignment := assignmentByDevice[deviceID]
|
|
card := DeviceAssignmentBoardCard{
|
|
DeviceID: deviceID,
|
|
DeviceName: boardDeviceName(deviceByID[deviceID], deviceID),
|
|
ProfileName: strings.TrimSpace(assignment.ProfileName),
|
|
MaxUnits: maxUnitsPerDevice,
|
|
}
|
|
for _, ref := range assignment.RecognitionUnits {
|
|
unit, ok := unitMap[ref]
|
|
if !ok {
|
|
continue
|
|
}
|
|
card.Units = append(card.Units, boardUnitFromRecognitionUnit(unit))
|
|
assignedRefs[ref] = struct{}{}
|
|
}
|
|
card.AssignedCount = len(card.Units)
|
|
card.Status = boardCardStatus(card.AssignedCount, maxUnitsPerDevice)
|
|
cards = append(cards, card)
|
|
}
|
|
sort.Slice(cards, func(i, j int) bool {
|
|
li := boardStatusRank(cards[i].Status)
|
|
lj := boardStatusRank(cards[j].Status)
|
|
if li != lj {
|
|
return li < lj
|
|
}
|
|
if cards[i].AssignedCount != cards[j].AssignedCount {
|
|
return cards[i].AssignedCount > cards[j].AssignedCount
|
|
}
|
|
return cards[i].DeviceID < cards[j].DeviceID
|
|
})
|
|
|
|
unassigned := make([]DeviceAssignmentBoardUnit, 0)
|
|
for _, unit := range units {
|
|
if _, ok := assignedRefs[unit.Ref]; ok {
|
|
continue
|
|
}
|
|
unassigned = append(unassigned, boardUnitFromRecognitionUnit(unit))
|
|
}
|
|
sort.Slice(unassigned, func(i, j int) bool {
|
|
if unassigned[i].SceneTemplateName != unassigned[j].SceneTemplateName {
|
|
return unassigned[i].SceneTemplateName < unassigned[j].SceneTemplateName
|
|
}
|
|
return unassigned[i].Name < unassigned[j].Name
|
|
})
|
|
|
|
stats := DeviceAssignmentBoardStats{
|
|
TotalUnits: len(units),
|
|
TotalDevices: len(deviceIDs),
|
|
AssignedUnits: len(assignedRefs),
|
|
UnassignedUnits: len(unassigned),
|
|
}
|
|
if stats.TotalDevices > 0 {
|
|
stats.AverageLoad = float64(stats.AssignedUnits) / float64(stats.TotalDevices)
|
|
}
|
|
for _, card := range cards {
|
|
if card.Status == "full" {
|
|
stats.OverloadedDevices++
|
|
}
|
|
}
|
|
return &DeviceAssignmentBoard{
|
|
MaxUnitsPerDevice: maxUnitsPerDevice,
|
|
Stats: stats,
|
|
Cards: cards,
|
|
Unassigned: unassigned,
|
|
}
|
|
}
|
|
|
|
func deviceAssignmentFromRecord(record storage.DeviceAssignmentRecord) (*DeviceAssignmentAsset, error) {
|
|
raw := map[string]any{}
|
|
if strings.TrimSpace(record.BodyJSON) != "" {
|
|
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
unitRefs := make([]string, 0)
|
|
if items, ok := raw["recognition_units"].([]any); ok {
|
|
for _, item := range items {
|
|
if v := strings.TrimSpace(stringValue(item)); v != "" {
|
|
unitRefs = append(unitRefs, v)
|
|
}
|
|
}
|
|
}
|
|
return &DeviceAssignmentAsset{
|
|
DeviceID: record.DeviceID,
|
|
ProfileName: firstString(record.ProfileName, stringValue(raw["profile_name"])),
|
|
Description: firstString(record.Description, stringValue(raw["description"])),
|
|
RecognitionUnits: unitRefs,
|
|
RecognitionCount: len(unitRefs),
|
|
}, nil
|
|
}
|
|
|
|
func normalizeMaxUnitsPerDevice(v int) int {
|
|
if v < 1 {
|
|
return DefaultMaxUnitsPerDevice()
|
|
}
|
|
if v > 8 {
|
|
return 8
|
|
}
|
|
return v
|
|
}
|
|
|
|
func boardDeviceName(dev *models.Device, fallback string) string {
|
|
if dev == nil {
|
|
return fallback
|
|
}
|
|
return firstString(strings.TrimSpace(dev.DisplayName()), fallback)
|
|
}
|
|
|
|
func boardUnitFromRecognitionUnit(unit RecognitionUnitAsset) DeviceAssignmentBoardUnit {
|
|
return DeviceAssignmentBoardUnit{
|
|
Ref: unit.Ref,
|
|
SceneTemplateName: unit.SceneTemplateName,
|
|
Name: unit.Name,
|
|
DisplayName: unit.DisplayName,
|
|
VideoSourceRef: unit.VideoSourceRef,
|
|
OutputChannel: firstString(unit.OutputChannel, unit.Name),
|
|
}
|
|
}
|
|
|
|
func boardCardStatus(count int, max int) string {
|
|
if count <= 0 {
|
|
return "idle"
|
|
}
|
|
if count >= max {
|
|
return "full"
|
|
}
|
|
if count*2 >= max {
|
|
return "busy"
|
|
}
|
|
return "low"
|
|
}
|
|
|
|
func boardStatusRank(status string) int {
|
|
switch status {
|
|
case "full":
|
|
return 0
|
|
case "busy":
|
|
return 1
|
|
case "low":
|
|
return 2
|
|
case "idle":
|
|
return 3
|
|
default:
|
|
return 4
|
|
}
|
|
}
|
|
|
|
func (s *ConfigPreviewService) GetDeviceAssignment(deviceID string) (*DeviceAssignmentAsset, error) {
|
|
if s == nil || s.assets == nil {
|
|
return nil, fmt.Errorf("asset repository is not configured")
|
|
}
|
|
record, err := s.assets.GetDeviceAssignment(strings.TrimSpace(deviceID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if record == nil {
|
|
return nil, os.ErrNotExist
|
|
}
|
|
return deviceAssignmentFromRecord(*record)
|
|
}
|
|
|
|
func (s *ConfigPreviewService) SaveDeviceAssignment(asset DeviceAssignmentAsset) error {
|
|
if s == nil || s.assets == nil {
|
|
return fmt.Errorf("asset repository is not configured")
|
|
}
|
|
deviceID := strings.TrimSpace(asset.DeviceID)
|
|
if deviceID == "" {
|
|
return fmt.Errorf("device id is required")
|
|
}
|
|
if strings.TrimSpace(asset.ProfileName) == "" {
|
|
return fmt.Errorf("scene template is required")
|
|
}
|
|
seen := map[string]struct{}{}
|
|
for _, ref := range asset.RecognitionUnits {
|
|
profileName, _, err := parseRecognitionUnitRef(ref)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if profileName != asset.ProfileName {
|
|
return fmt.Errorf("all recognition units must belong to scene template %q", asset.ProfileName)
|
|
}
|
|
if _, ok := seen[ref]; ok {
|
|
return fmt.Errorf("duplicate recognition unit: %s", ref)
|
|
}
|
|
seen[ref] = struct{}{}
|
|
}
|
|
raw := map[string]any{
|
|
"device_id": deviceID,
|
|
"profile_name": strings.TrimSpace(asset.ProfileName),
|
|
"description": strings.TrimSpace(asset.Description),
|
|
"recognition_units": asset.RecognitionUnits,
|
|
}
|
|
body, err := marshalConfigJSON(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.assets.SaveDeviceAssignment(deviceID, strings.TrimSpace(asset.ProfileName), strings.TrimSpace(asset.Description), string(body))
|
|
}
|
|
|
|
func (s *ConfigPreviewService) SaveDeviceAssignmentBoard(assignments map[string][]string) error {
|
|
if s == nil || s.assets == nil {
|
|
return fmt.Errorf("asset repository is not configured")
|
|
}
|
|
existing, err := s.ListDeviceAssignments()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
existingByDevice := make(map[string]DeviceAssignmentAsset, len(existing))
|
|
for _, item := range existing {
|
|
existingByDevice[item.DeviceID] = item
|
|
}
|
|
for deviceID, refs := range assignments {
|
|
deviceID = strings.TrimSpace(deviceID)
|
|
if deviceID == "" {
|
|
continue
|
|
}
|
|
cleanRefs := make([]string, 0, len(refs))
|
|
seen := map[string]struct{}{}
|
|
for _, ref := range refs {
|
|
ref = strings.TrimSpace(ref)
|
|
if ref == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[ref]; ok {
|
|
continue
|
|
}
|
|
seen[ref] = struct{}{}
|
|
cleanRefs = append(cleanRefs, ref)
|
|
}
|
|
if len(cleanRefs) == 0 {
|
|
if _, ok := existingByDevice[deviceID]; ok {
|
|
if err := s.assets.DeleteDeviceAssignment(deviceID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
firstUnit, err := s.GetRecognitionUnit(cleanRefs[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
profileName := firstUnit.SceneTemplateName
|
|
for _, ref := range cleanRefs[1:] {
|
|
unit, err := s.GetRecognitionUnit(ref)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if unit.SceneTemplateName != profileName {
|
|
return fmt.Errorf("device %q contains recognition units from different scene templates", deviceID)
|
|
}
|
|
}
|
|
description := existingByDevice[deviceID].Description
|
|
if err := s.SaveDeviceAssignment(DeviceAssignmentAsset{
|
|
DeviceID: deviceID,
|
|
ProfileName: profileName,
|
|
Description: description,
|
|
RecognitionUnits: cleanRefs,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) DeleteDeviceAssignment(deviceID string) error {
|
|
if s == nil || s.assets == nil {
|
|
return fmt.Errorf("asset repository is not configured")
|
|
}
|
|
return s.assets.DeleteDeviceAssignment(strings.TrimSpace(deviceID))
|
|
}
|
|
|
|
func (s *ConfigPreviewService) deviceAssignmentsReferencingRecognitionUnit(ref string) ([]string, error) {
|
|
items, err := s.ListDeviceAssignments()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]string, 0)
|
|
for _, item := range items {
|
|
for _, unitRef := range item.RecognitionUnits {
|
|
if unitRef == ref {
|
|
out = append(out, item.DeviceID)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) SaveIntegrationServiceAsset(asset ConfigIntegrationServiceAsset) error {
|
|
if s == nil || s.assets == nil {
|
|
return fmt.Errorf("asset repository is not configured")
|
|
}
|
|
name := strings.TrimSpace(asset.Name)
|
|
if err := validateConfigName(name); err != nil {
|
|
return fmt.Errorf("invalid third-party service name: %w", err)
|
|
}
|
|
serviceType := strings.TrimSpace(asset.Type)
|
|
if serviceType == "" {
|
|
return fmt.Errorf("third-party service type is required")
|
|
}
|
|
raw := map[string]any{
|
|
"name": name,
|
|
"type": serviceType,
|
|
"description": strings.TrimSpace(asset.Description),
|
|
"enabled": asset.Enabled,
|
|
}
|
|
configMap := map[string]any{}
|
|
switch serviceType {
|
|
case "object_storage":
|
|
if asset.ObjectStorage == nil {
|
|
return fmt.Errorf("object storage config is required")
|
|
}
|
|
setAnyString(configMap, "endpoint", asset.ObjectStorage.Endpoint)
|
|
setAnyString(configMap, "bucket", asset.ObjectStorage.Bucket)
|
|
setAnyString(configMap, "access_key", asset.ObjectStorage.AccessKey)
|
|
setAnyString(configMap, "secret_key", asset.ObjectStorage.SecretKey)
|
|
for _, key := range []string{"endpoint", "bucket", "access_key", "secret_key"} {
|
|
if strings.TrimSpace(stringValue(configMap[key])) == "" {
|
|
return fmt.Errorf("object storage %s is required", key)
|
|
}
|
|
}
|
|
case "token_service":
|
|
if asset.TokenService == nil {
|
|
return fmt.Errorf("token service config is required")
|
|
}
|
|
setAnyString(configMap, "get_token_url", asset.TokenService.GetTokenURL)
|
|
setAnyString(configMap, "username", asset.TokenService.Username)
|
|
setAnyString(configMap, "password", asset.TokenService.Password)
|
|
setAnyString(configMap, "tenant_code", asset.TokenService.TenantCode)
|
|
if strings.TrimSpace(stringValue(configMap["get_token_url"])) == "" {
|
|
return fmt.Errorf("token service get_token_url is required")
|
|
}
|
|
case "alarm_service":
|
|
if asset.AlarmService == nil {
|
|
return fmt.Errorf("alarm service config is required")
|
|
}
|
|
setAnyString(configMap, "put_message_url", asset.AlarmService.PutMessageURL)
|
|
setAnyString(configMap, "username", asset.AlarmService.Username)
|
|
setAnyString(configMap, "password", asset.AlarmService.Password)
|
|
setAnyString(configMap, "tenant_code", asset.AlarmService.TenantCode)
|
|
if strings.TrimSpace(stringValue(configMap["put_message_url"])) == "" {
|
|
return fmt.Errorf("alarm service put_message_url is required")
|
|
}
|
|
default:
|
|
return fmt.Errorf("unsupported third-party service type: %s", serviceType)
|
|
}
|
|
raw["config"] = configMap
|
|
body, err := marshalConfigJSON(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.assets.SaveIntegrationService(name, serviceType, strings.TrimSpace(asset.Description), asset.Enabled, string(body))
|
|
}
|
|
|
|
func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name string) ([]string, error) {
|
|
if s == nil || s.assets == nil {
|
|
return nil, nil
|
|
}
|
|
units, err := s.ListRecognitionUnits()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return nil, nil
|
|
}
|
|
seen := map[string]struct{}{}
|
|
refs := make([]string, 0)
|
|
for _, unit := range units {
|
|
record, err := s.assets.GetRecognitionUnit(unit.SceneTemplateName, unit.Name)
|
|
if err != nil || record == nil {
|
|
continue
|
|
}
|
|
raw := map[string]any{}
|
|
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
|
|
continue
|
|
}
|
|
for _, slot := range []string{"object_storage_main", "token_service_main", "alarm_service_main"} {
|
|
if strings.TrimSpace(bindingStringFromAnyMap(raw, "service_bindings", slot, "service_ref")) == name {
|
|
if _, ok := seen[unit.SceneTemplateName]; !ok {
|
|
seen[unit.SceneTemplateName] = struct{}{}
|
|
refs = append(refs, unit.SceneTemplateName)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(refs)
|
|
return refs, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) profileNamesReferencingVideoSource(name string) ([]string, error) {
|
|
if s == nil || s.assets == nil {
|
|
return nil, nil
|
|
}
|
|
units, err := s.ListRecognitionUnits()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return nil, nil
|
|
}
|
|
seen := map[string]struct{}{}
|
|
refs := make([]string, 0)
|
|
for _, unit := range units {
|
|
if strings.TrimSpace(unit.VideoSourceRef) == name {
|
|
if _, ok := seen[unit.SceneTemplateName]; !ok {
|
|
seen[unit.SceneTemplateName] = struct{}{}
|
|
refs = append(refs, unit.SceneTemplateName)
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(refs)
|
|
return refs, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) profileNamesReferencingOverlay(name string) ([]string, error) {
|
|
if s == nil || s.assets == nil {
|
|
return nil, nil
|
|
}
|
|
records, err := s.assets.ListProfiles()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return nil, nil
|
|
}
|
|
refs := make([]string, 0)
|
|
for _, record := range records {
|
|
var raw map[string]any
|
|
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
|
|
continue
|
|
}
|
|
for _, overlay := range profileOverlayNames(raw) {
|
|
if strings.TrimSpace(overlay) == name {
|
|
refs = append(refs, record.Name)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(refs)
|
|
return refs, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) readAssetJSON(kind string, name string) (map[string]any, string, error) {
|
|
if s == nil || s.assets == nil {
|
|
return nil, "", fmt.Errorf("asset repository is not configured")
|
|
}
|
|
raw, path, ok, err := s.readRepoAssetJSON(kind, name)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if ok {
|
|
return raw, path, nil
|
|
}
|
|
return nil, "", os.ErrNotExist
|
|
}
|
|
|
|
func (s *ConfigPreviewService) readRepoAssetJSON(kind string, name string) (map[string]any, string, bool, error) {
|
|
if err := validateConfigName(name); err != nil {
|
|
return nil, "", false, err
|
|
}
|
|
var (
|
|
record *storage.AssetRecord
|
|
err error
|
|
)
|
|
switch kind {
|
|
case "templates":
|
|
record, err = s.assets.GetTemplate(name)
|
|
case "profiles":
|
|
record, err = s.assets.GetProfile(name)
|
|
case "overlays":
|
|
record, err = s.assets.GetOverlay(name)
|
|
default:
|
|
return nil, "", false, fmt.Errorf("unsupported asset kind: %s", kind)
|
|
}
|
|
if err != nil {
|
|
return nil, "", true, err
|
|
}
|
|
if record == nil {
|
|
return nil, "", false, nil
|
|
}
|
|
var raw map[string]any
|
|
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
|
|
return nil, "", true, err
|
|
}
|
|
if raw == nil {
|
|
raw = map[string]any{}
|
|
}
|
|
if strings.TrimSpace(record.Description) != "" {
|
|
raw["description"] = record.Description
|
|
}
|
|
if kind == "profiles" {
|
|
if strings.TrimSpace(record.TemplateName) != "" {
|
|
raw["primary_template_name"] = record.TemplateName
|
|
}
|
|
if strings.TrimSpace(record.BusinessName) != "" && stringValue(raw["business_name"]) == "" {
|
|
raw["business_name"] = record.BusinessName
|
|
}
|
|
}
|
|
return raw, repoAssetPath(kind, name), true, nil
|
|
}
|
|
|
|
func cloneMap(in map[string]any) map[string]any {
|
|
if len(in) == 0 {
|
|
return map[string]any{}
|
|
}
|
|
out := make(map[string]any, len(in))
|
|
for k, v := range in {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func bindingAny(bindings map[string]any, slot string, field string) any {
|
|
entry, _ := bindings[slot].(map[string]any)
|
|
return entry[field]
|
|
}
|
|
|
|
func (s *ConfigPreviewService) templateAssetFromRecord(record storage.AssetRecord, origin string, readOnly bool) (*ConfigTemplateAsset, error) {
|
|
var raw map[string]any
|
|
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
if raw == nil {
|
|
raw = map[string]any{}
|
|
}
|
|
if strings.TrimSpace(record.Description) != "" {
|
|
raw["description"] = record.Description
|
|
}
|
|
return buildTemplateAsset(raw, repoAssetPath("templates", record.Name), origin, readOnly), nil
|
|
}
|
|
|
|
func buildTemplateAsset(raw map[string]any, path string, origin string, readOnly bool) *ConfigTemplateAsset {
|
|
templateMap, _ := raw["template"].(map[string]any)
|
|
paramsMap, _ := raw["params"].(map[string]any)
|
|
nodes, _ := templateMap["nodes"].([]any)
|
|
edges, _ := templateMap["edges"].([]any)
|
|
slots, _ := parseTemplateSlots(raw)
|
|
advanced := cloneMap(paramsMap)
|
|
for _, key := range []string{
|
|
"minio_endpoint",
|
|
"minio_bucket",
|
|
"external_get_token_url",
|
|
"external_put_message_url",
|
|
"tenant_code",
|
|
} {
|
|
delete(advanced, key)
|
|
}
|
|
if len(advanced) == 0 {
|
|
advanced = nil
|
|
}
|
|
return &ConfigTemplateAsset{
|
|
Name: firstString(raw["name"], filepath.Base(strings.TrimSuffix(path, filepath.Ext(path)))),
|
|
Path: path,
|
|
Origin: origin,
|
|
ReadOnly: readOnly,
|
|
Description: stringValue(raw["description"]),
|
|
Source: stringValue(raw["source"]),
|
|
Slots: slots,
|
|
NodeCount: len(nodes),
|
|
EdgeCount: len(edges),
|
|
MinIOEndpoint: stringValue(paramsMap["minio_endpoint"]),
|
|
MinIOBucket: stringValue(paramsMap["minio_bucket"]),
|
|
ExternalGetTokenURL: stringValue(paramsMap["external_get_token_url"]),
|
|
ExternalPutMessageURL: stringValue(paramsMap["external_put_message_url"]),
|
|
TenantCode: valueString(paramsMap["tenant_code"]),
|
|
AdvancedParams: advanced,
|
|
Raw: raw,
|
|
}
|
|
}
|
|
|
|
func integrationServiceAssetFromRecord(record storage.IntegrationServiceRecord) (*ConfigIntegrationServiceAsset, error) {
|
|
var raw map[string]any
|
|
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
if raw == nil {
|
|
raw = map[string]any{}
|
|
}
|
|
configMap, _ := raw["config"].(map[string]any)
|
|
sourceMap := raw
|
|
if len(configMap) > 0 {
|
|
sourceMap = configMap
|
|
}
|
|
item := &ConfigIntegrationServiceAsset{
|
|
Name: firstString(raw["name"], record.Name),
|
|
Path: repoAssetPath("integration_services", record.Name),
|
|
Type: firstString(record.ServiceType, stringValue(raw["type"])),
|
|
Description: firstString(raw["description"], record.Description),
|
|
Enabled: boolValue(raw["enabled"], record.Enabled),
|
|
Raw: raw,
|
|
}
|
|
item.TypeLabel = integrationTypeLabel(item.Type)
|
|
switch item.Type {
|
|
case "object_storage":
|
|
item.ObjectStorage = &ObjectStorageConfig{
|
|
Endpoint: stringValue(sourceMap["endpoint"]),
|
|
Bucket: stringValue(sourceMap["bucket"]),
|
|
AccessKey: firstString(sourceMap["access_key"], stringValue(sourceMap["minio_access_key"])),
|
|
SecretKey: firstString(sourceMap["secret_key"], stringValue(sourceMap["minio_secret_key"])),
|
|
}
|
|
item.AddressSummary = strings.TrimSpace(strings.Trim(strings.Join([]string{item.ObjectStorage.Endpoint, item.ObjectStorage.Bucket}, " / "), " /"))
|
|
case "token_service":
|
|
item.TokenService = &TokenServiceConfig{
|
|
GetTokenURL: stringValue(sourceMap["get_token_url"]),
|
|
Username: stringValue(sourceMap["username"]),
|
|
Password: stringValue(sourceMap["password"]),
|
|
TenantCode: stringValue(sourceMap["tenant_code"]),
|
|
}
|
|
item.AddressSummary = item.TokenService.GetTokenURL
|
|
case "alarm_service":
|
|
item.AlarmService = &AlarmServiceConfig{
|
|
PutMessageURL: stringValue(sourceMap["put_message_url"]),
|
|
Username: stringValue(sourceMap["username"]),
|
|
Password: stringValue(sourceMap["password"]),
|
|
TenantCode: stringValue(sourceMap["tenant_code"]),
|
|
}
|
|
item.AddressSummary = item.AlarmService.PutMessageURL
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func videoSourceAssetFromRecord(record storage.VideoSourceRecord) (*ConfigVideoSourceAsset, error) {
|
|
var raw map[string]any
|
|
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
if raw == nil {
|
|
raw = map[string]any{}
|
|
}
|
|
configMap, _ := raw["config"].(map[string]any)
|
|
sourceMap := raw
|
|
if len(configMap) > 0 {
|
|
sourceMap = configMap
|
|
}
|
|
item := &ConfigVideoSourceAsset{
|
|
Name: firstString(raw["name"], record.Name),
|
|
Path: repoAssetPath("video_sources", record.Name),
|
|
SourceType: firstString(record.SourceType, stringValue(raw["source_type"])),
|
|
Area: firstString(raw["area"], record.Area),
|
|
Description: firstString(raw["description"], record.Description),
|
|
SourceTypeLabel: videoSourceTypeLabel(firstString(record.SourceType, stringValue(raw["source_type"]))),
|
|
Config: VideoSourceConfig{
|
|
URL: stringValue(sourceMap["url"]),
|
|
Resolution: stringValue(sourceMap["resolution"]),
|
|
FrameSize: stringValue(sourceMap["frame_size"]),
|
|
FPS: valueString(sourceMap["fps"]),
|
|
VideoFormat: stringValue(sourceMap["video_format"]),
|
|
FocalLength: stringValue(sourceMap["focal_length"]),
|
|
MountHeight: stringValue(sourceMap["mount_height"]),
|
|
MountAngle: stringValue(sourceMap["mount_angle"]),
|
|
},
|
|
Raw: raw,
|
|
}
|
|
item.SourceSummary = item.Config.URL
|
|
return item, nil
|
|
}
|
|
|
|
func integrationTypeLabel(v string) string {
|
|
switch strings.TrimSpace(v) {
|
|
case "object_storage":
|
|
return "对象存储"
|
|
case "token_service":
|
|
return "认证服务"
|
|
case "alarm_service":
|
|
return "告警服务"
|
|
default:
|
|
return strings.TrimSpace(v)
|
|
}
|
|
}
|
|
|
|
func videoSourceTypeLabel(v string) string {
|
|
switch strings.TrimSpace(v) {
|
|
case "rtsp":
|
|
return "RTSP"
|
|
case "rtmp":
|
|
return "RTMP"
|
|
case "file":
|
|
return "文件"
|
|
case "usb_camera":
|
|
return "USB 摄像头"
|
|
default:
|
|
return strings.TrimSpace(v)
|
|
}
|
|
}
|
|
|
|
func validateVideoSourceName(name string) error {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return fmt.Errorf("不能为空")
|
|
}
|
|
if strings.Contains(name, "..") {
|
|
return fmt.Errorf("不能包含 '..'")
|
|
}
|
|
if strings.ContainsAny(name, `/\`) {
|
|
return fmt.Errorf("不能包含 / 或 \\")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) templateIsBuiltin(name string) bool {
|
|
return isStandardTemplateName(name)
|
|
}
|
|
|
|
func isStandardTemplateName(name string) bool {
|
|
name = canonicalTemplateAssetName(name)
|
|
return strings.HasPrefix(strings.TrimSpace(name), "std_")
|
|
}
|
|
|
|
func canonicalTemplateAssetName(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
if next := strings.TrimSpace(legacyBuiltinTemplateAliases[name]); next != "" {
|
|
return next
|
|
}
|
|
return name
|
|
}
|
|
|
|
func stringValue(v any) string {
|
|
if s, ok := v.(string); ok {
|
|
return strings.TrimSpace(s)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func valueString(v any) string {
|
|
switch value := v.(type) {
|
|
case string:
|
|
return strings.TrimSpace(value)
|
|
case float64:
|
|
if float64(int(value)) == value {
|
|
return fmt.Sprintf("%d", int(value))
|
|
}
|
|
return fmt.Sprintf("%v", value)
|
|
case int:
|
|
return fmt.Sprintf("%d", value)
|
|
case int64:
|
|
return fmt.Sprintf("%d", value)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func boolValue(v any, fallback bool) bool {
|
|
switch value := v.(type) {
|
|
case bool:
|
|
return value
|
|
case float64:
|
|
return value != 0
|
|
case int:
|
|
return value != 0
|
|
case int64:
|
|
return value != 0
|
|
case string:
|
|
value = strings.TrimSpace(strings.ToLower(value))
|
|
return value == "1" || value == "true" || value == "yes" || value == "on"
|
|
default:
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
func firstString(v any, fallback string) string {
|
|
if got := stringValue(v); got != "" {
|
|
return got
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func intValue(v any) int {
|
|
switch value := v.(type) {
|
|
case int:
|
|
return value
|
|
case int64:
|
|
return int(value)
|
|
case float64:
|
|
return int(value)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|