// backend\milestone_cameras.go package main import ( "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "strings" "github.com/jackc/pgx/v5" ) type milestoneCameraPatchDevice struct { DeviceID string InventoryNumber string ChildID string MilestoneHardwareID string MilestoneDeviceID string DeviceType string Enabled bool RecordingEnabled bool } type milestoneRecordingStorageRef struct { ID string `json:"id"` DisplayName string `json:"displayName,omitempty"` Name string `json:"name,omitempty"` DiskPath string `json:"diskPath,omitempty"` Mounted bool `json:"mounted,omitempty"` } type milestoneCameraStream struct { Index int `json:"index"` Codec string `json:"codec"` FPS *int `json:"fps"` JPEGQuality *int `json:"jPEGQuality"` Quality *int `json:"quality"` Resolution string `json:"resolution"` EdgeStorageSupported bool `json:"edgeStorageSupported"` } type milestoneCameraDetail struct { ID string `json:"id"` MilestoneDeviceID string `json:"milestoneDeviceId"` MilestoneHardwareID string `json:"milestoneHardwareId"` Enabled bool `json:"enabled"` RecordingEnabled bool `json:"recordingEnabled"` RecordingFramerate *int `json:"recordingFramerate"` RecordKeyframesOnly bool `json:"recordKeyframesOnly"` RecordingStorage *milestoneRecordingStorageRef `json:"recordingStorage,omitempty"` ArchiveStorages []milestoneArchiveStorage `json:"archiveStorages"` Streams []milestoneCameraStream `json:"streams"` } type updateMilestoneCameraStreamRequest struct { Index int `json:"index"` Codec string `json:"codec"` FPS *int `json:"fps"` JPEGQuality *int `json:"jPEGQuality"` Quality *int `json:"quality"` Resolution string `json:"resolution"` } type updateMilestoneCameraDetailsRequest struct { Enabled *bool `json:"enabled"` RecordingEnabled *bool `json:"recordingEnabled"` RecordingFramerate *int `json:"recordingFramerate"` RecordKeyframesOnly *bool `json:"recordKeyframesOnly"` RecordingStorageID *string `json:"recordingStorageId"` Streams []updateMilestoneCameraStreamRequest `json:"streams"` } func (s *Server) getMilestoneCameraPatchDevice( ctx context.Context, deviceID string, childID string, ) (milestoneCameraPatchDevice, error) { var camera milestoneCameraPatchDevice err := s.db.QueryRow( ctx, ` SELECT devices.id::TEXT, devices.inventory_number, milestone_hardware_child_devices.id::TEXT, milestone_hardware_child_devices.milestone_hardware_id, milestone_hardware_child_devices.milestone_device_id, milestone_hardware_child_devices.device_type, milestone_hardware_child_devices.enabled, COALESCE(milestone_hardware_child_devices.recording_enabled, false) 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( &camera.DeviceID, &camera.InventoryNumber, &camera.ChildID, &camera.MilestoneHardwareID, &camera.MilestoneDeviceID, &camera.DeviceType, &camera.Enabled, &camera.RecordingEnabled, ) return camera, err } func (s *Server) handleGetMilestoneCameraDetails(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 } cameraDevice, err := s.getMilestoneCameraPatchDevice(r.Context(), deviceID, childID) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "Milestone-Kamera wurde nicht gefunden") return } if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte nicht geladen werden") return } if strings.TrimSpace(strings.ToLower(cameraDevice.DeviceType)) != "camera" && strings.TrimSpace(strings.ToLower(cameraDevice.DeviceType)) != "cameras" { writeError(w, http.StatusBadRequest, "Das Child-Device ist keine Kamera") return } config, err := s.getMilestoneRuntimeConfig(r.Context()) if err != nil { writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") return } cameraData, err := getMilestoneNamedDevice( r.Context(), config, "cameras", cameraDevice.MilestoneDeviceID, ) if err != nil { writeError(w, http.StatusBadGateway, "Milestone-Kamera konnte nicht geladen werden") return } archiveStorages := []milestoneArchiveStorage{} var currentStorage *milestoneStorage if recordingStorage, ok := cameraData["recordingStorage"].(map[string]any); ok { recordingStorageID := strings.TrimSpace(milestoneStringValue(recordingStorage["id"])) if recordingStorageID != "" { currentStorage, err = getMilestoneStorage( r.Context(), config, recordingStorageID, ) if err != nil { writeError(w, http.StatusBadGateway, "Milestone-Storage konnte nicht geladen werden") return } archiveStorages, err = getMilestoneArchiveStorages( r.Context(), config, recordingStorageID, ) if err != nil { writeError(w, http.StatusBadGateway, "Milestone-Archive-Storage konnte nicht geladen werden") return } } } camera := mapMilestoneCameraDetail(cameraData, cameraDevice, currentStorage, archiveStorages) writeJSON(w, http.StatusOK, map[string]any{ "camera": camera, }) } func (s *Server) handleUpdateMilestoneCameraDetails(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 } cameraDevice, err := s.getMilestoneCameraPatchDevice(r.Context(), deviceID, childID) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "Milestone-Kamera wurde nicht gefunden") return } if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte nicht geladen werden") return } if strings.TrimSpace(strings.ToLower(cameraDevice.DeviceType)) != "camera" && strings.TrimSpace(strings.ToLower(cameraDevice.DeviceType)) != "cameras" { writeError(w, http.StatusBadRequest, "Das Child-Device ist keine Kamera") return } var input updateMilestoneCameraDetailsRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } config, err := s.getMilestoneRuntimeConfig(r.Context()) if err != nil { writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") return } currentCamera, err := getMilestoneNamedDevice( r.Context(), config, "cameras", cameraDevice.MilestoneDeviceID, ) if err != nil { writeError(w, http.StatusBadGateway, "Milestone-Kamera konnte nicht geladen werden") return } if input.Enabled != nil { currentCamera["enabled"] = *input.Enabled } if input.RecordingEnabled != nil { currentCamera["recordingEnabled"] = *input.RecordingEnabled } if input.RecordingFramerate != nil { currentCamera["recordingFramerate"] = *input.RecordingFramerate } if input.RecordKeyframesOnly != nil { currentCamera["recordKeyframesOnly"] = *input.RecordKeyframesOnly } if input.RecordingStorageID != nil { recordingStorageID := strings.TrimSpace(*input.RecordingStorageID) if recordingStorageID == "" { currentCamera["recordingStorage"] = nil } else { currentCamera["recordingStorage"] = map[string]any{ "type": "storages", "id": recordingStorageID, } } } applyMilestoneCameraStreamUpdates(currentCamera, input.Streams) if err := patchMilestoneNamedDevice( r.Context(), config, "cameras", cameraDevice.MilestoneDeviceID, currentCamera, ); err != nil { writeError(w, http.StatusBadGateway, "Milestone-Kamera konnte nicht aktualisiert werden") return } _, err = s.db.Exec( r.Context(), ` UPDATE milestone_hardware_child_devices SET enabled = COALESCE($2, enabled), recording_enabled = COALESCE($3, recording_enabled), recording_storage_id = COALESCE($4, recording_storage_id), updated_at = now() WHERE id = $1 `, cameraDevice.ChildID, input.Enabled, input.RecordingEnabled, input.RecordingStorageID, ) if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte lokal nicht aktualisiert werden") return } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, }) } func mapMilestoneCameraDetail( cameraData map[string]any, cameraDevice milestoneCameraPatchDevice, currentStorage *milestoneStorage, archiveStorages []milestoneArchiveStorage, ) milestoneCameraDetail { detail := milestoneCameraDetail{ ID: cameraDevice.ChildID, MilestoneDeviceID: cameraDevice.MilestoneDeviceID, MilestoneHardwareID: cameraDevice.MilestoneHardwareID, Enabled: milestoneBoolValue(cameraData["enabled"]), RecordingEnabled: milestoneBoolValue(cameraData["recordingEnabled"]), RecordKeyframesOnly: milestoneBoolValue(cameraData["recordKeyframesOnly"]), Streams: extractMilestoneCameraStreams(cameraData), ArchiveStorages: archiveStorages, } if value, ok := milestoneIntPointerValue(cameraData["recordingFramerate"]); ok { detail.RecordingFramerate = value } if currentStorage != nil && strings.TrimSpace(currentStorage.ID) != "" { detail.RecordingStorage = &milestoneRecordingStorageRef{ ID: currentStorage.ID, DisplayName: currentStorage.DisplayName, Name: currentStorage.Name, DiskPath: currentStorage.DiskPath, Mounted: currentStorage.Mounted, } } else if recordingStorage, ok := cameraData["recordingStorage"].(map[string]any); ok { recordingStorageID := strings.TrimSpace(milestoneStringValue(recordingStorage["id"])) if recordingStorageID != "" { detail.RecordingStorage = &milestoneRecordingStorageRef{ ID: recordingStorageID, } } } return detail } func extractMilestoneCameraStreams(cameraData map[string]any) []milestoneCameraStream { settings, ok := cameraData["settings"].([]any) if !ok { return []milestoneCameraStream{} } streams := []milestoneCameraStream{} for _, rawSetting := range settings { setting, ok := rawSetting.(map[string]any) if !ok { continue } rawStreams, ok := setting["stream"].([]any) if !ok { continue } for index, rawStream := range rawStreams { streamMap, ok := rawStream.(map[string]any) if !ok { continue } stream := milestoneCameraStream{ Index: index, Codec: milestoneStringValue(streamMap["codec"]), Resolution: milestoneStringValue(streamMap["resolution"]), EdgeStorageSupported: milestoneBoolValue(streamMap["edgeStorageSupported"]), } if value, ok := milestoneIntPointerValue(streamMap["FPS"]); ok { stream.FPS = value } if value, ok := milestoneIntPointerValue(streamMap["jPEGQuality"]); ok { stream.JPEGQuality = value } if value, ok := milestoneIntPointerValue(streamMap["quality"]); ok { stream.Quality = value } streams = append(streams, stream) } } return streams } type milestoneCameraListItem struct { ID string `json:"id"` Name string `json:"name"` DisplayName string `json:"displayName"` Type string `json:"type"` Enabled bool `json:"enabled"` RecordingEnabled bool `json:"recordingEnabled"` RecordingStorageID string `json:"recordingStorageId"` HardwareID string `json:"hardwareId"` HardwareDisplayName string `json:"hardwareDisplayName"` } func (s *Server) getMilestoneHardwareDisplayNameMap( ctx context.Context, ) (map[string]string, error) { rows, err := s.db.Query( ctx, ` SELECT COALESCE(milestone_hardware_id, ''), COALESCE(milestone_display_name, inventory_number, '') FROM devices WHERE COALESCE(milestone_hardware_id, '') <> '' `, ) if err != nil { return nil, err } defer rows.Close() out := map[string]string{} for rows.Next() { var hardwareID string var displayName string if err := rows.Scan(&hardwareID, &displayName); err != nil { return nil, err } hardwareID = strings.TrimSpace(hardwareID) displayName = strings.TrimSpace(displayName) if hardwareID == "" || displayName == "" { continue } if _, exists := out[hardwareID]; !exists { out[hardwareID] = displayName } } if err := rows.Err(); err != nil { return nil, err } return out, nil } func getMilestoneDeviceList( ctx context.Context, config milestoneRuntimeConfig, collection string, hardwareDisplayNames map[string]string, ) ([]milestoneCameraListItem, error) { 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 } setMilestoneRequestHeaders(request, config, false) response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) if err != nil { return nil, err } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return nil, err } if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, fmt.Errorf("milestone %s list failed: status=%d", collection, response.StatusCode) } 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 data, ok := decoded["data"].(map[string]any); ok { if array, ok := data["array"].([]any); ok { rawItems = array } } deviceType := "camera" if collection == "microphones" { deviceType = "microphone" } items := make([]milestoneCameraListItem, 0, len(rawItems)) for _, raw := range rawItems { item, ok := raw.(map[string]any) if !ok { continue } id := milestoneNamedDeviceID(item) if id == "" { continue } name := milestoneStringValue(item["name"]) displayName := milestoneStringValue(item["displayName"]) if name == "" { name = displayName } if displayName == "" { displayName = name } if name == "" { continue } enabled := milestoneItemEnabledValue(item) recordingEnabled := false if v, ok := item["recordingEnabled"].(bool); ok { recordingEnabled = v } recordingStorageID := "" if rs, ok := item["recordingStorage"].(map[string]any); ok { recordingStorageID = strings.TrimSpace(milestoneStringValue(rs["id"])) } if recordingStorageID == "" { if relations, ok := item["relations"].(map[string]any); ok { if rs, ok := relations["recordingStorage"].(map[string]any); ok { recordingStorageID = strings.TrimSpace(milestoneStringValue(rs["id"])) } } } hardwareID := milestoneNamedDeviceParentID(item) hardwareDisplayName := strings.TrimSpace(hardwareDisplayNames[hardwareID]) items = append(items, milestoneCameraListItem{ ID: id, Name: name, DisplayName: displayName, Type: deviceType, Enabled: enabled, RecordingEnabled: recordingEnabled, RecordingStorageID: recordingStorageID, HardwareID: hardwareID, HardwareDisplayName: hardwareDisplayName, }) } return items, nil } func (s *Server) handleListMilestoneCameras(w http.ResponseWriter, r *http.Request) { config, err := s.getMilestoneRuntimeConfig(r.Context()) if err != nil { writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") return } hardwareDisplayNames, err := s.getMilestoneHardwareDisplayNameMap(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Hardwarenamen konnten nicht geladen werden") return } cameras, err := getMilestoneDeviceList(r.Context(), config, "cameras", hardwareDisplayNames) if err != nil { log.Printf("Milestone cameras list failed: %v", err) writeError(w, http.StatusBadGateway, "Milestone-Kameras konnten nicht geladen werden") return } microphones, err := getMilestoneDeviceList(r.Context(), config, "microphones", hardwareDisplayNames) if err != nil { log.Printf("Milestone microphones list failed: %v", err) writeError(w, http.StatusBadGateway, "Milestone-Mikrofone konnten nicht geladen werden") return } all := make([]milestoneCameraListItem, 0, len(cameras)+len(microphones)) all = append(all, cameras...) all = append(all, microphones...) writeJSON(w, http.StatusOK, map[string]any{"devices": all}) } type patchMilestoneDeviceRequest struct { Enabled *bool `json:"enabled"` RecordingEnabled *bool `json:"recordingEnabled"` } func (s *Server) handlePatchMilestoneDevice(w http.ResponseWriter, r *http.Request) { collection := strings.TrimSpace(r.PathValue("collection")) deviceID := strings.TrimSpace(r.PathValue("id")) if collection != "cameras" && collection != "microphones" { writeError(w, http.StatusBadRequest, "Ungültige Gerätekategorie") return } if deviceID == "" { writeError(w, http.StatusBadRequest, "Geräte-ID fehlt") return } var input patchMilestoneDeviceRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return } payload := map[string]any{} if input.Enabled != nil { payload["enabled"] = *input.Enabled } if input.RecordingEnabled != nil { payload["recordingEnabled"] = *input.RecordingEnabled } if len(payload) == 0 { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) return } config, err := s.getMilestoneRuntimeConfig(r.Context()) if err != nil { writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") return } if err := patchMilestoneNamedDevice(r.Context(), config, collection, deviceID, payload); err != nil { log.Printf("Milestone device patch failed: collection=%s id=%s error=%v", collection, deviceID, err) writeError(w, http.StatusBadGateway, "Gerät konnte nicht aktualisiert werden") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func applyMilestoneCameraStreamUpdates( cameraData map[string]any, streamUpdates []updateMilestoneCameraStreamRequest, ) { if len(streamUpdates) == 0 { return } settings, ok := cameraData["settings"].([]any) if !ok { return } updatesByIndex := map[int]updateMilestoneCameraStreamRequest{} for _, streamUpdate := range streamUpdates { updatesByIndex[streamUpdate.Index] = streamUpdate } for _, rawSetting := range settings { setting, ok := rawSetting.(map[string]any) if !ok { continue } rawStreams, ok := setting["stream"].([]any) if !ok { continue } for index, rawStream := range rawStreams { streamMap, ok := rawStream.(map[string]any) if !ok { continue } update, exists := updatesByIndex[index] if !exists { continue } streamMap["codec"] = update.Codec streamMap["resolution"] = update.Resolution if update.FPS != nil { streamMap["FPS"] = *update.FPS } if update.JPEGQuality != nil { streamMap["jPEGQuality"] = *update.JPEGQuality } if update.Quality != nil { streamMap["quality"] = *update.Quality } } } }