// backend\milestone_api.go package main import ( "bytes" "context" "database/sql" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/jackc/pgx/v5" ) type milestoneRuntimeConfig struct { Host string AccessToken string TokenType string SkipTLSVerify bool TokenExpiresAt *time.Time } type milestoneHardwarePatchDevice struct { ID string InventoryNumber string MilestoneHardwareID string MilestoneDisplayName string IPAddress string MilestonePort string MilestoneEnabled bool } type updateMilestoneHardwareRequest struct { Name *string `json:"name"` DisplayName *string `json:"displayName"` MilestoneDisplayName *string `json:"milestoneDisplayName"` IPAddress *string `json:"ipAddress"` Address *string `json:"address"` Port *string `json:"port"` MilestonePort *string `json:"milestonePort"` Enabled *bool `json:"enabled"` } type updateMilestoneChildDeviceRequest struct { Enabled *bool `json:"enabled"` } type milestoneChildPatchDevice struct { DeviceID string ChildID string MilestoneDeviceID string DeviceType string Enabled bool } func (s *Server) getMilestoneChildPatchDevice( ctx context.Context, deviceID string, childID string, ) (milestoneChildPatchDevice, error) { var child milestoneChildPatchDevice err := s.db.QueryRow( ctx, ` SELECT devices.id::TEXT, milestone_hardware_child_devices.id::TEXT, milestone_hardware_child_devices.milestone_device_id, milestone_hardware_child_devices.device_type, milestone_hardware_child_devices.enabled FROM milestone_hardware_child_devices INNER JOIN devices ON devices.milestone_hardware_id = milestone_hardware_child_devices.milestone_hardware_id WHERE devices.id = $1 AND milestone_hardware_child_devices.id = $2 LIMIT 1 `, deviceID, childID, ).Scan( &child.DeviceID, &child.ChildID, &child.MilestoneDeviceID, &child.DeviceType, &child.Enabled, ) return child, err } func (s *Server) handleUpdateMilestoneChildDevice(w http.ResponseWriter, r *http.Request) { deviceID := strings.TrimSpace(r.PathValue("id")) childID := strings.TrimSpace(r.PathValue("childId")) if deviceID == "" || childID == "" { writeError(w, http.StatusBadRequest, "Geräte-ID oder Child-ID fehlt") return } var input updateMilestoneChildDeviceRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } if input.Enabled == nil { writeError(w, http.StatusBadRequest, "enabled fehlt") return } child, err := s.getMilestoneChildPatchDevice(r.Context(), deviceID, childID) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "Milestone-Child-Device wurde nicht gefunden") return } if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Child-Device konnte nicht geladen werden") return } config, err := s.getMilestoneRuntimeConfig(r.Context()) if err != nil { writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") return } collection, err := milestoneCollectionForChildDeviceType(child.DeviceType) if err != nil { writeError(w, http.StatusBadRequest, "Unbekannter Milestone-Child-Device-Typ") return } if err := patchMilestoneNamedDevice( r.Context(), config, collection, child.MilestoneDeviceID, map[string]any{ "enabled": *input.Enabled, }, ); err != nil { writeError(w, http.StatusBadGateway, "Milestone-Child-Device konnte nicht aktualisiert werden") return } _, err = s.db.Exec( r.Context(), ` UPDATE milestone_hardware_child_devices SET enabled = $2, updated_at = now() WHERE id = $1 `, child.ChildID, *input.Enabled, ) if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Child-Device konnte lokal nicht aktualisiert werden") return } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "id": child.ChildID, "enabled": *input.Enabled, }) } func milestoneCollectionForChildDeviceType(deviceType string) (string, error) { switch strings.TrimSpace(strings.ToLower(deviceType)) { case "camera", "cameras": return "cameras", nil case "microphone", "microphones": return "microphones", nil case "input", "inputs": return "inputs", nil case "output", "outputs": return "outputs", nil case "speaker", "speakers": return "speakers", nil case "metadata": return "metadata", nil default: return "", fmt.Errorf("unsupported milestone child device type: %s", deviceType) } } func (s *Server) getMilestoneHardwarePatchDevice( ctx context.Context, deviceID string, ) (milestoneHardwarePatchDevice, error) { var device milestoneHardwarePatchDevice err := s.db.QueryRow( ctx, ` SELECT id::TEXT, inventory_number, COALESCE(milestone_hardware_id, ''), COALESCE(milestone_display_name, ''), COALESCE(ip_address, ''), COALESCE(milestone_port, ''), milestone_enabled FROM devices WHERE id = $1 LIMIT 1 `, deviceID, ).Scan( &device.ID, &device.InventoryNumber, &device.MilestoneHardwareID, &device.MilestoneDisplayName, &device.IPAddress, &device.MilestonePort, &device.MilestoneEnabled, ) return device, err } func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Request) { user, ok := userFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "Unauthorized") return } deviceID := strings.TrimSpace(r.PathValue("id")) if deviceID == "" { writeError(w, http.StatusBadRequest, "Geräte-ID fehlt") return } var input updateMilestoneHardwareRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } if input.Name == nil && input.DisplayName == nil && input.MilestoneDisplayName == nil && input.IPAddress == nil && input.Address == nil && input.Port == nil && input.MilestonePort == nil && input.Enabled == nil { writeError(w, http.StatusBadRequest, "Keine Milestone-Felder zum Aktualisieren übergeben") return } device, err := s.getMilestoneHardwarePatchDevice(r.Context(), deviceID) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden") return } if err != nil { writeError(w, http.StatusInternalServerError, "Gerät konnte nicht geladen werden") return } if strings.TrimSpace(device.MilestoneHardwareID) == "" { writeError(w, http.StatusBadRequest, "Dieses Gerät ist kein Milestone-Gerät") return } nextDisplayName := strings.TrimSpace(device.MilestoneDisplayName) nextIPAddress := strings.TrimSpace(device.IPAddress) nextPort := strings.TrimSpace(device.MilestonePort) nextEnabled := device.MilestoneEnabled payload := map[string]any{} changes := []DeviceHistoryChange{} requestedDisplayName := input.Name if requestedDisplayName == nil { requestedDisplayName = input.DisplayName } if requestedDisplayName == nil { requestedDisplayName = input.MilestoneDisplayName } if requestedDisplayName != nil { nextDisplayName = strings.TrimSpace(*requestedDisplayName) // Wenn der Client einen Namen sendet, immer an Milestone weitergeben. payload["name"] = nextDisplayName if nextDisplayName != strings.TrimSpace(device.MilestoneDisplayName) { changes = appendHistoryChange( changes, "milestoneDisplayName", "Milestone-Anzeigename", device.MilestoneDisplayName, nextDisplayName, ) } } requestedAddress := input.Address if requestedAddress == nil { requestedAddress = input.IPAddress } requestedPort := input.Port if requestedPort == nil { requestedPort = input.MilestonePort } if requestedAddress != nil || requestedPort != nil { addressValue := device.IPAddress if requestedAddress != nil { addressValue = *requestedAddress } portValue := device.MilestonePort if requestedPort != nil { portValue = *requestedPort } nextAddress := normalizeMilestoneHardwareAddress(addressValue, portValue) nextIPAddress, parsedPort := extractMilestoneHardwareAddressParts(nextAddress) nextPort = normalizeMilestonePort(portValue) if nextPort == "" { nextPort = parsedPort } if nextIPAddress == "" { nextIPAddress = strings.TrimSpace(addressValue) } // Wenn der Client Adresse oder Port sendet, immer an Milestone weitergeben. payload["address"] = nextAddress if nextIPAddress != strings.TrimSpace(device.IPAddress) { changes = appendHistoryChange( changes, "ipAddress", "IP-Adresse", device.IPAddress, nextIPAddress, ) } if nextPort != strings.TrimSpace(device.MilestonePort) { changes = appendHistoryChange( changes, "milestonePort", "Milestone-Port", device.MilestonePort, nextPort, ) } } if input.Enabled != nil { nextEnabled = *input.Enabled // Wenn der Client den Status sendet, immer an Milestone weitergeben. payload["enabled"] = nextEnabled if nextEnabled != device.MilestoneEnabled { changes = appendHistoryChange( changes, "milestoneEnabled", "Milestone-Status", formatHistoryBool(device.MilestoneEnabled), formatHistoryBool(nextEnabled), ) } } if len(payload) == 0 { writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "id": device.ID, "milestoneDisplayName": device.MilestoneDisplayName, "ipAddress": device.IPAddress, "milestonePort": device.MilestonePort, "milestoneEnabled": device.MilestoneEnabled, }) return } config, err := s.getMilestoneRuntimeConfig(r.Context()) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt") return } if err != nil { s.logAndWriteUserError( w, r, user, http.StatusBadGateway, "milestone_hardware", "load_or_refresh_token", "Milestone-Token konnte nicht geladen oder erneuert werden", err, LogFields{ "deviceId": device.ID, "inventoryNumber": device.InventoryNumber, "milestoneHardwareId": device.MilestoneHardwareID, }, ) return } if strings.TrimSpace(config.Host) == "" { writeError(w, http.StatusBadRequest, "Milestone Host fehlt") return } if strings.TrimSpace(config.AccessToken) == "" { writeError(w, http.StatusBadRequest, "Milestone-Token fehlt und konnte nicht automatisch erneuert werden") return } if err := updateMilestoneHardware( r.Context(), config, device.MilestoneHardwareID, payload, device.MilestoneDisplayName, nextDisplayName, ); err != nil { s.logAndWriteUserError( w, r, user, http.StatusBadGateway, "milestone_hardware", "update_hardware", "Milestone-Gerät konnte nicht aktualisiert werden", err, LogFields{ "deviceId": device.ID, "inventoryNumber": device.InventoryNumber, "milestoneHardwareId": device.MilestoneHardwareID, "payload": payload, }, ) return } tx, err := s.db.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Gerät konnte lokal nicht aktualisiert werden") return } defer tx.Rollback(r.Context()) _, err = tx.Exec( r.Context(), ` UPDATE devices SET milestone_display_name = $2, ip_address = $3, milestone_port = $4, milestone_enabled = $5, milestone_synced_at = now(), updated_at = now() WHERE id = $1 `, device.ID, nextDisplayName, nextIPAddress, nextPort, nextEnabled, ) if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Gerät konnte lokal nicht aktualisiert werden") return } _, err = tx.Exec( r.Context(), ` UPDATE milestone_hardware_child_devices SET name = CASE WHEN name = $2 THEN $3 WHEN name LIKE $2 || ' - %' THEN $3 || substring(name FROM char_length($2) + 1) WHEN name LIKE $2 || ' %' THEN $3 || substring(name FROM char_length($2) + 1) ELSE name END, display_name = CASE WHEN display_name = $2 THEN $3 WHEN display_name LIKE $2 || ' - %' THEN $3 || substring(display_name FROM char_length($2) + 1) WHEN display_name LIKE $2 || ' %' THEN $3 || substring(display_name FROM char_length($2) + 1) ELSE display_name END, short_name = CASE WHEN short_name = $2 THEN $3 WHEN short_name LIKE $2 || ' - %' THEN $3 || substring(short_name FROM char_length($2) + 1) WHEN short_name LIKE $2 || ' %' THEN $3 || substring(short_name FROM char_length($2) + 1) ELSE short_name END, updated_at = now() WHERE milestone_hardware_id = $1 `, device.MilestoneHardwareID, device.MilestoneDisplayName, nextDisplayName, ) if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Child-Devices konnten lokal nicht aktualisiert werden") return } if len(changes) > 0 { if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "updated", changes); err != nil { writeError(w, http.StatusInternalServerError, "Geräteverlauf konnte nicht gespeichert werden") return } } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Gerät konnte lokal nicht aktualisiert werden") return } if err := s.publishDeviceChangeEvent( r.Context(), user.ID, device.ID, "device.milestone.hardware", "Milestone-Gerät aktualisiert", formatText("Gerät %s wurde in Milestone aktualisiert.", device.InventoryNumber), LogFields{ "inventoryNumber": device.InventoryNumber, "milestoneHardwareId": device.MilestoneHardwareID, "payload": payload, }, ); err != nil { logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{ "deviceId": device.ID, "type": "device.milestone.hardware", }) } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "id": device.ID, "milestoneDisplayName": nextDisplayName, "ipAddress": nextIPAddress, "milestonePort": nextPort, "milestoneEnabled": nextEnabled, }) } func normalizeMilestoneHardwareAddress(value string, port string) string { value = strings.TrimSpace(value) port = normalizeMilestonePort(port) if value == "" || value == "-" { return "" } rawValue := value if !strings.HasPrefix(strings.ToLower(value), "http://") && !strings.HasPrefix(strings.ToLower(value), "https://") { value = "http://" + strings.TrimRight(value, "/") } parsedURL, err := url.Parse(value) if err != nil || parsedURL.Hostname() == "" { host := strings.TrimRight(rawValue, "/") host = strings.TrimPrefix(host, "http://") host = strings.TrimPrefix(host, "https://") if existingHost, existingPort, found := strings.Cut(host, ":"); found { host = strings.TrimSpace(existingHost) if port == "" && isOnlyDigits(strings.TrimSpace(existingPort)) { port = strings.TrimSpace(existingPort) } } if port == "" { port = defaultMilestonePortForScheme("http") } scheme := milestoneSchemeForPort(port) return scheme + "://" + host + ":" + port + "/" } host := parsedURL.Hostname() if port == "" { port = normalizeMilestonePort(parsedURL.Port()) } if port == "" { port = defaultMilestonePortForScheme(parsedURL.Scheme) } scheme := milestoneSchemeForPort(port) return scheme + "://" + host + ":" + port + "/" } func milestoneSchemeForPort(port string) string { port = normalizeMilestonePort(port) if port == "443" { return "https" } return "http" } func normalizeMilestonePort(value string) string { value = strings.TrimSpace(value) value = strings.TrimPrefix(value, ":") if value == "-" { return "" } return value } type milestoneHardwareUpdateEnvelope struct { Data map[string]any `json:"data"` } const milestoneTokenRefreshBeforeExpiry = 2 * time.Minute func (s *Server) getMilestoneRuntimeConfig( ctx context.Context, ) (milestoneRuntimeConfig, error) { config, err := s.loadMilestoneRuntimeConfig(ctx) if err != nil { return config, err } if shouldRefreshMilestoneToken(config) { if err := s.refreshMilestoneRuntimeToken(ctx); err != nil { return config, err } return s.loadMilestoneRuntimeConfig(ctx) } return config, nil } func shouldRefreshMilestoneToken(config milestoneRuntimeConfig) bool { if strings.TrimSpace(config.AccessToken) == "" { return true } if config.TokenExpiresAt == nil { return true } return time.Now().After(config.TokenExpiresAt.Add(-milestoneTokenRefreshBeforeExpiry)) } func (s *Server) loadMilestoneRuntimeConfig( ctx context.Context, ) (milestoneRuntimeConfig, error) { var config milestoneRuntimeConfig var tokenExpiresAt sql.NullTime err := s.db.QueryRow( ctx, ` SELECT host, COALESCE(pgp_sym_decrypt(access_token_encrypted, $1), ''), COALESCE(token_type, ''), skip_tls_verify, token_expires_at FROM milestone_settings WHERE id = 1 LIMIT 1 `, s.milestoneEncryptionKey(), ).Scan( &config.Host, &config.AccessToken, &config.TokenType, &config.SkipTLSVerify, &tokenExpiresAt, ) if err != nil { return config, err } if tokenExpiresAt.Valid { config.TokenExpiresAt = &tokenExpiresAt.Time } return config, nil } func (s *Server) refreshMilestoneRuntimeToken(ctx context.Context) error { credentials, err := s.getMilestoneCredentials(ctx) if err != nil { return err } if credentials.Host == "" || credentials.Username == "" || credentials.Password == "" { return errors.New("Milestone-Zugangsdaten sind unvollständig") } tokenResponse, err := s.requestMilestoneToken(ctx, credentials) if err != nil { return err } accessToken := strings.TrimSpace(tokenResponse.AccessToken) if accessToken == "" { return errors.New("Milestone hat keinen Access-Token zurückgegeben") } tokenType := strings.TrimSpace(tokenResponse.TokenType) if tokenType == "" { tokenType = "Bearer" } expiresIn := tokenResponse.ExpiresIn if expiresIn <= 0 { expiresIn = 3600 } tokenExpiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second) _, err = s.db.Exec( ctx, ` UPDATE milestone_settings SET access_token_encrypted = pgp_sym_encrypt($1, $2), token_type = $3, token_expires_at = $4, token_updated_at = now(), updated_at = now() WHERE id = 1 `, accessToken, s.milestoneEncryptionKey(), tokenType, tokenExpiresAt, ) return err } func updateMilestoneHardware( ctx context.Context, config milestoneRuntimeConfig, hardwareID string, updates map[string]any, previousHardwareName string, requestedHardwareName string, ) error { currentHardware, err := getMilestoneHardware(ctx, config, hardwareID) if err != nil { return err } currentHardwareName := milestoneStringValue(currentHardware["name"]) if currentHardwareName == "" { currentHardwareName = milestoneStringValue(currentHardware["displayName"]) } oldHardwareName := strings.TrimSpace(previousHardwareName) if oldHardwareName == "" { oldHardwareName = currentHardwareName } newHardwareName := strings.TrimSpace(requestedHardwareName) if value, exists := updates["name"]; exists { if updateName := milestoneStringValue(value); updateName != "" { newHardwareName = updateName } } _, shouldUpdateDeviceNames := updates["name"] hardwareDevices := []milestoneNamedDevice{} if shouldUpdateDeviceNames { embeddedDevices := milestoneNamedDevicesFromHardware(currentHardware) camerasWithDisabled, err := requestMilestoneNamedDevicesByHardware( ctx, config, "cameras", hardwareID, ) if err != nil { return err } microphonesWithDisabled, err := requestMilestoneNamedDevicesByHardware( ctx, config, "microphones", hardwareID, ) if err != nil { return err } hardwareDevices = mergeMilestoneNamedDevices( embeddedDevices, camerasWithDisabled, microphonesWithDisabled, ) } for key, value := range updates { currentHardware[key] = value } if err := putMilestoneHardware(ctx, config, hardwareID, currentHardware); err != nil { return err } if !shouldUpdateDeviceNames || newHardwareName == "" || len(hardwareDevices) == 0 { return nil } return updateMilestoneChildDeviceBaseNames( ctx, config, hardwareDevices, oldHardwareName, newHardwareName, ) } func getMilestoneHardware( ctx context.Context, config milestoneRuntimeConfig, hardwareID string, ) (map[string]any, error) { requestURL := strings.TrimRight(config.Host, "/") + "/API/rest/v1/hardware/" + url.PathEscape(hardwareID) 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 hardware get failed", LogFields{ "statusCode": response.StatusCode, "responseBody": string(responseBody), "hardwareId": hardwareID, }, "milestone hardware get failed: status=%d body=%s", response.StatusCode, string(responseBody), ) } var envelope milestoneHardwareUpdateEnvelope if err := json.Unmarshal(responseBody, &envelope); err == nil && envelope.Data != nil { return envelope.Data, nil } var direct map[string]any if err := json.Unmarshal(responseBody, &direct); err != nil { return nil, err } if data, ok := direct["data"].(map[string]any); ok { return data, nil } return direct, nil } func putMilestoneHardware( ctx context.Context, config milestoneRuntimeConfig, hardwareID string, payload map[string]any, ) error { body, err := json.Marshal(payload) if err != nil { return err } requestURL := strings.TrimRight(config.Host, "/") + "/API/rest/v1/hardware/" + url.PathEscape(hardwareID) request, err := http.NewRequestWithContext( ctx, http.MethodPut, 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 hardware put failed", LogFields{ "statusCode": response.StatusCode, "responseBody": string(responseBody), "hardwareId": hardwareID, "payload": payload, }, "milestone hardware put failed: status=%d body=%s", response.StatusCode, string(responseBody), ) } return nil } func replaceMilestoneDeviceBaseName( cameraName string, oldHardwareName string, newHardwareName string, ) (string, bool) { cameraName = strings.TrimSpace(cameraName) oldHardwareName = strings.TrimSpace(oldHardwareName) newHardwareName = strings.TrimSpace(newHardwareName) if cameraName == "" || newHardwareName == "" { return cameraName, false } if oldHardwareName != "" { if cameraName == oldHardwareName { return newHardwareName, true } if strings.HasPrefix(cameraName, oldHardwareName) { suffix := strings.TrimPrefix(cameraName, oldHardwareName) if suffix == "" || strings.HasPrefix(suffix, " ") || strings.HasPrefix(suffix, "-") || strings.HasPrefix(suffix, "_") { return newHardwareName + suffix, true } } } // Fallback für Fälle, in denen die Hardware bereits umbenannt wurde, // die Kamera aber noch z. B. "Camera_102_Test - Kamera 1" heißt. if baseName, suffix, found := strings.Cut(cameraName, " - "); found { baseName = strings.TrimSpace(baseName) suffix = strings.TrimSpace(suffix) if baseName != "" && suffix != "" && baseName != newHardwareName { return newHardwareName + " - " + suffix, true } } return cameraName, false } func milestoneStringValue(value any) string { text, ok := value.(string) if !ok { return "" } return strings.TrimSpace(text) } type milestoneNamedDevice struct { Collection string ID string Name string DisplayName string ShortName string Enabled bool } 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")..., ) devices = append( devices, milestoneNamedDevicesFromHardwareCollection(hardware, "inputs")..., ) devices = append( devices, milestoneNamedDevicesFromHardwareCollection(hardware, "outputs")..., ) devices = append( devices, milestoneNamedDevicesFromHardwareCollection(hardware, "speakers")..., ) devices = append( devices, milestoneNamedDevicesFromHardwareCollection(hardware, "metadata")..., ) 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) devices = append(devices, milestoneNamedDevice{ Collection: collection, ID: id, Name: name, DisplayName: displayName, ShortName: shortName, Enabled: enabled, }) } 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", "inputs", "outputs", "speakers", "metadata": 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) devices = append(devices, milestoneNamedDevice{ Collection: collection, ID: id, Name: name, DisplayName: displayName, ShortName: shortName, Enabled: enabled, }) } 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 }