// backend\milestone_child_devices.go package main import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" ) type milestoneNamedDevice struct { Collection string ID string Name string DisplayName string ShortName string Enabled bool RecordingEnabled bool RecordingStorageID string } func replaceMilestoneDeviceBaseName( deviceName string, oldHardwareName string, newHardwareName string, ) (string, bool) { deviceName = strings.TrimSpace(deviceName) oldHardwareName = strings.TrimSpace(oldHardwareName) newHardwareName = strings.TrimSpace(newHardwareName) if deviceName == "" || newHardwareName == "" { return deviceName, false } if oldHardwareName != "" { if deviceName == oldHardwareName { return newHardwareName, true } if strings.HasPrefix(deviceName, oldHardwareName) { suffix := strings.TrimPrefix(deviceName, oldHardwareName) if suffix == "" || strings.HasPrefix(suffix, " ") || strings.HasPrefix(suffix, "-") || strings.HasPrefix(suffix, "_") { return newHardwareName + suffix, true } } } if baseName, suffix, found := strings.Cut(deviceName, " - "); found { baseName = strings.TrimSpace(baseName) suffix = strings.TrimSpace(suffix) if baseName != "" && suffix != "" && baseName != newHardwareName { return newHardwareName + " - " + suffix, true } } return deviceName, false } func milestoneItemEnabledValue(item map[string]any) bool { enabled := true if value, ok := item["enabled"].(bool); ok { enabled = value } if value, ok := item["disabled"].(bool); ok { enabled = !value } return enabled } func milestoneNamedDevicesFromHardware(hardware map[string]any) []milestoneNamedDevice { devices := []milestoneNamedDevice{} devices = append(devices, milestoneNamedDevicesFromHardwareCollection(hardware, "cameras")...) devices = append(devices, milestoneNamedDevicesFromHardwareCollection(hardware, "microphones")...) return devices } func milestoneNamedDevicesFromHardwareCollectionOnly( hardware map[string]any, collection string, ) []milestoneNamedDevice { return milestoneNamedDevicesFromHardwareCollection(hardware, collection) } func milestoneNamedDevicesFromHardwareCollection( hardware map[string]any, collection string, ) []milestoneNamedDevice { rawItems, ok := hardware[collection].([]any) if !ok { return nil } devices := make([]milestoneNamedDevice, 0, len(rawItems)) for _, rawItem := range rawItems { item, ok := rawItem.(map[string]any) if !ok { continue } id := milestoneNamedDeviceID(item) if id == "" { continue } name := milestoneStringValue(item["name"]) displayName := milestoneStringValue(item["displayName"]) shortName := milestoneStringValue(item["shortName"]) if name == "" { name = displayName } if name == "" { name = shortName } if displayName == "" { displayName = name } if shortName == "" { shortName = name } if name == "" { continue } enabled := milestoneItemEnabledValue(item) recordingEnabled := false if value, ok := item["recordingEnabled"].(bool); ok { recordingEnabled = value } recordingStorageID := "" if recordingStorage, ok := item["recordingStorage"].(map[string]any); ok { recordingStorageID = strings.TrimSpace(milestoneStringValue(recordingStorage["id"])) } devices = append(devices, milestoneNamedDevice{ Collection: collection, ID: id, Name: name, DisplayName: displayName, ShortName: shortName, Enabled: enabled, RecordingEnabled: recordingEnabled, RecordingStorageID: recordingStorageID, }) } return devices } func mergeMilestoneNamedDevices(groups ...[]milestoneNamedDevice) []milestoneNamedDevice { result := []milestoneNamedDevice{} seen := map[string]bool{} for _, group := range groups { for _, device := range group { key := device.Collection + ":" + device.ID if device.ID == "" || seen[key] { continue } seen[key] = true result = append(result, device) } } return result } func requestMilestoneNamedDevicesByHardware( ctx context.Context, config milestoneRuntimeConfig, collection string, hardwareID string, ) ([]milestoneNamedDevice, error) { switch collection { case "cameras", "microphones": default: return nil, fmt.Errorf("unsupported milestone device collection: %s", collection) } requestURL := strings.TrimRight(config.Host, "/") + "/API/rest/v1/" + collection + "?disabled" request, err := http.NewRequestWithContext( ctx, http.MethodGet, requestURL, nil, ) if err != nil { return nil, err } tokenType := strings.TrimSpace(config.TokenType) if tokenType == "" { tokenType = "Bearer" } request.Header.Set("Authorization", tokenType+" "+config.AccessToken) request.Header.Set("Accept", "application/json") response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) if err != nil { return nil, err } defer response.Body.Close() responseBody, _ := io.ReadAll(response.Body) if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, newAppErrorf( "milestone child device list failed", LogFields{ "statusCode": response.StatusCode, "responseBody": string(responseBody), "collection": collection, "hardwareId": hardwareID, }, "milestone child device list failed: collection=%s hardwareId=%s status=%d body=%s", collection, hardwareID, response.StatusCode, string(responseBody), ) } return milestoneNamedDevicesFromListResponse(responseBody, collection, hardwareID) } func milestoneNamedDevicesFromListResponse( body []byte, collection string, hardwareID string, ) ([]milestoneNamedDevice, error) { var decoded map[string]any if err := json.Unmarshal(body, &decoded); err != nil { return nil, err } var rawItems []any if array, ok := decoded["array"].([]any); ok { rawItems = array } else if array, ok := decoded["data"].([]any); ok { rawItems = array } else if data, ok := decoded["data"].(map[string]any); ok { if array, ok := data["array"].([]any); ok { rawItems = array } } if len(rawItems) == 0 { return nil, nil } devices := make([]milestoneNamedDevice, 0, len(rawItems)) hardwareID = strings.TrimSpace(hardwareID) for _, rawItem := range rawItems { item, ok := rawItem.(map[string]any) if !ok { continue } parentID := milestoneNamedDeviceParentID(item) if parentID != hardwareID { continue } id := milestoneNamedDeviceID(item) if id == "" { continue } displayName := milestoneStringValue(item["displayName"]) shortName := milestoneStringValue(item["shortName"]) name := milestoneStringValue(item["name"]) if name == "" { name = displayName } if name == "" { name = shortName } if displayName == "" { displayName = name } if shortName == "" { shortName = name } if name == "" { continue } enabled := milestoneItemEnabledValue(item) recordingEnabled := false if value, ok := item["recordingEnabled"].(bool); ok { recordingEnabled = value } recordingStorageID := "" if recordingStorage, ok := item["recordingStorage"].(map[string]any); ok { recordingStorageID = strings.TrimSpace(milestoneStringValue(recordingStorage["id"])) } devices = append(devices, milestoneNamedDevice{ Collection: collection, ID: id, Name: name, DisplayName: displayName, ShortName: shortName, Enabled: enabled, RecordingEnabled: recordingEnabled, RecordingStorageID: recordingStorageID, }) } return devices, nil } func milestoneNamedDeviceParentID(item map[string]any) string { relations, ok := item["relations"].(map[string]any) if !ok { return "" } parent, ok := relations["parent"].(map[string]any) if !ok { return "" } return milestoneStringValue(parent["id"]) } func milestoneNamedDeviceID(item map[string]any) string { if id := milestoneStringValue(item["id"]); id != "" { return id } relations, ok := item["relations"].(map[string]any) if !ok { return "" } self, ok := relations["self"].(map[string]any) if !ok { return "" } return milestoneStringValue(self["id"]) } func updateMilestoneChildDeviceBaseNames( ctx context.Context, config milestoneRuntimeConfig, devices []milestoneNamedDevice, oldHardwareName string, newHardwareName string, ) error { for _, device := range devices { nextName, changed := replaceMilestoneDeviceBaseName( device.Name, oldHardwareName, newHardwareName, ) if !changed { continue } if err := patchMilestoneNamedDevice( ctx, config, device.Collection, device.ID, map[string]any{ "name": nextName, "displayName": nextName, "shortName": nextName, }, ); err != nil { return err } } return nil } func patchMilestoneNamedDevice( ctx context.Context, config milestoneRuntimeConfig, collection string, deviceID string, payload map[string]any, ) error { switch collection { case "cameras", "microphones": default: return fmt.Errorf("unsupported milestone device collection: %s", collection) } body, err := json.Marshal(payload) if err != nil { return err } requestURL := strings.TrimRight(config.Host, "/") + "/API/rest/v1/" + collection + "/" + url.PathEscape(deviceID) request, err := http.NewRequestWithContext( ctx, http.MethodPatch, requestURL, bytes.NewReader(body), ) if err != nil { return err } tokenType := strings.TrimSpace(config.TokenType) if tokenType == "" { tokenType = "Bearer" } request.Header.Set("Authorization", tokenType+" "+config.AccessToken) request.Header.Set("Accept", "application/json") request.Header.Set("Content-Type", "application/json") response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) if err != nil { return err } defer response.Body.Close() responseBody, _ := io.ReadAll(response.Body) if response.StatusCode < 200 || response.StatusCode >= 300 { return newAppErrorf( "milestone child device patch failed", LogFields{ "statusCode": response.StatusCode, "responseBody": string(responseBody), "collection": collection, "deviceId": deviceID, "payload": payload, }, "milestone child device patch failed: collection=%s id=%s status=%d body=%s", collection, deviceID, response.StatusCode, string(responseBody), ) } return nil }