From 5af98965146e96e6b9a8df75bd2f2c0ca22f1781 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Fri, 29 May 2026 16:18:28 +0200 Subject: [PATCH] added child devices --- backend/admin_milestone.go | 318 ++++++- backend/devices.go | 138 ++- backend/milestone_api.go | 883 +++++++++++++++++- backend/milestone_devices.go | 163 ++++ backend/routes.go | 4 + backend/setup/main.go | 22 +- frontend/src/components/types.ts | 13 + .../src/pages/devices/ChildDeviceCard.tsx | 112 +++ .../src/pages/devices/DeviceFormFields.tsx | 666 ++++++++----- .../src/pages/devices/DeviceModalTabs.tsx | 7 +- frontend/src/pages/devices/DevicesPage.tsx | 74 +- .../src/pages/devices/EditDeviceModal.tsx | 18 +- 12 files changed, 2139 insertions(+), 279 deletions(-) create mode 100644 frontend/src/pages/devices/ChildDeviceCard.tsx diff --git a/backend/admin_milestone.go b/backend/admin_milestone.go index 8d3f22c..971edc9 100644 --- a/backend/admin_milestone.go +++ b/backend/admin_milestone.go @@ -154,6 +154,12 @@ type milestoneHardwareDevice struct { SerialNumber string FirmwareVersion string Enabled bool + Cameras []milestoneNamedDevice + Microphones []milestoneNamedDevice + Inputs []milestoneNamedDevice + Outputs []milestoneNamedDevice + Speakers []milestoneNamedDevice + Metadata []milestoneNamedDevice } func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Request) { @@ -790,6 +796,17 @@ func (s *Server) requestMilestoneHardwareDevices( return nil, err } + hardwareDetails, err := s.requestMilestoneHardwareDetails( + ctx, + host, + hardwareID, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + displayName := strings.TrimSpace(hardware.DisplayName) if displayName == "" { displayName = strings.TrimSpace(hardware.Name) @@ -831,7 +848,144 @@ func (s *Server) requestMilestoneHardwareDevices( enabled = !*hardware.Disabled } - ipAddress, port := extractMilestoneHardwareAddressParts(hardware.Address) + hardwareAddress := strings.TrimSpace(milestoneStringValue(hardwareDetails["address"])) + if hardwareAddress == "" { + hardwareAddress = hardware.Address + } + + ipAddress, port := extractMilestoneHardwareAddressParts(hardwareAddress) + + embeddedCameras := milestoneNamedDevicesFromHardwareCollection( + hardwareDetails, + "cameras", + ) + + embeddedMicrophones := milestoneNamedDevicesFromHardwareCollection( + hardwareDetails, + "microphones", + ) + + embeddedInputs := milestoneNamedDevicesFromHardwareCollection( + hardwareDetails, + "inputs", + ) + + embeddedOutputs := milestoneNamedDevicesFromHardwareCollection( + hardwareDetails, + "outputs", + ) + + embeddedSpeakers := milestoneNamedDevicesFromHardwareCollection( + hardwareDetails, + "speakers", + ) + + embeddedMetadata := milestoneNamedDevicesFromHardwareCollection( + hardwareDetails, + "metadata", + ) + + camerasWithDisabled, err := s.requestMilestoneNamedDevicesByHardware( + ctx, + host, + "cameras", + hardwareID, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + + microphonesWithDisabled, err := s.requestMilestoneNamedDevicesByHardware( + ctx, + host, + "microphones", + hardwareID, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + + inputsWithDisabled, err := s.requestMilestoneNamedDevicesByHardware( + ctx, + host, + "inputs", + hardwareID, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + + outputsWithDisabled, err := s.requestMilestoneNamedDevicesByHardware( + ctx, + host, + "outputs", + hardwareID, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + + speakersWithDisabled, err := s.requestMilestoneNamedDevicesByHardware( + ctx, + host, + "speakers", + hardwareID, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + + metadataWithDisabled, err := s.requestMilestoneNamedDevicesByHardware( + ctx, + host, + "metadata", + hardwareID, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + + cameras := mergeMilestoneNamedDevices( + embeddedCameras, + camerasWithDisabled, + ) + + microphones := mergeMilestoneNamedDevices( + embeddedMicrophones, + microphonesWithDisabled, + ) + + inputs := mergeMilestoneNamedDevices( + embeddedInputs, + inputsWithDisabled, + ) + + outputs := mergeMilestoneNamedDevices( + embeddedOutputs, + outputsWithDisabled, + ) + + speakers := mergeMilestoneNamedDevices( + embeddedSpeakers, + speakersWithDisabled, + ) + + metadata := mergeMilestoneNamedDevices( + embeddedMetadata, + metadataWithDisabled, + ) result = append(result, milestoneHardwareDevice{ HardwareID: hardwareID, @@ -846,26 +1000,100 @@ func (s *Server) requestMilestoneHardwareDevices( SerialNumber: strings.TrimSpace(settings.SerialNumber), FirmwareVersion: strings.TrimSpace(settings.FirmwareVersion), Enabled: enabled, + Cameras: cameras, + Microphones: microphones, + Inputs: inputs, + Outputs: outputs, + Speakers: speakers, + Metadata: metadata, }) } + cameraCount := 0 + microphoneCount := 0 + inputCount := 0 + outputCount := 0 + speakerCount := 0 + metadataCount := 0 + + for _, hardwareDevice := range result { + cameraCount += len(hardwareDevice.Cameras) + microphoneCount += len(hardwareDevice.Microphones) + inputCount += len(hardwareDevice.Inputs) + outputCount += len(hardwareDevice.Outputs) + speakerCount += len(hardwareDevice.Speakers) + metadataCount += len(hardwareDevice.Metadata) + } + log.Printf( - "Milestone hardware loaded successfully: recordingServerId=%s count=%d", + "Milestone hardware loaded successfully: recordingServerId=%s count=%d cameras=%d microphones=%d inputs=%d outputs=%d speakers=%d metadata=%d", recordingServerID, len(result), + cameraCount, + microphoneCount, + inputCount, + outputCount, + speakerCount, + metadataCount, ) return result, nil } +func (s *Server) requestMilestoneNamedDevicesByHardware( + ctx context.Context, + host string, + collection string, + hardwareID string, + accessToken string, + skipTLSVerify bool, +) ([]milestoneNamedDevice, error) { + switch collection { + case "cameras", "microphones": + default: + return nil, fmt.Errorf("unsupported milestone device collection: %s", collection) + } + + requestURL := strings.TrimRight(host, "/") + + "/API/rest/v1/" + + collection + + "?disabled" + + log.Printf( + "Requesting Milestone %s with disabled: url=%s hardwareId=%s skipTLSVerify=%t", + collection, + requestURL, + hardwareID, + skipTLSVerify, + ) + + body, err := requestMilestoneJSON( + ctx, + requestURL, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + + return milestoneNamedDevicesFromListResponse(body, collection, hardwareID) +} + func extractMilestoneHardwareIPAddress(value string) string { host, _ := extractMilestoneHardwareAddressParts(value) return host } -func extractMilestoneHardwarePort(value string) string { - _, port := extractMilestoneHardwareAddressParts(value) - return port +func defaultMilestonePortForScheme(scheme string) string { + switch strings.ToLower(strings.TrimSpace(scheme)) { + case "https": + return "443" + case "http": + return "80" + default: + return "80" + } } func extractMilestoneHardwareAddressParts(value string) (string, string) { @@ -876,7 +1104,24 @@ func extractMilestoneHardwareAddressParts(value string) (string, string) { parsedURL, err := url.Parse(value) if err == nil && parsedURL.Hostname() != "" { - return strings.TrimSpace(parsedURL.Hostname()), strings.TrimSpace(parsedURL.Port()) + scheme := strings.ToLower(strings.TrimSpace(parsedURL.Scheme)) + if scheme == "" { + scheme = "http" + } + + port := strings.TrimSpace(parsedURL.Port()) + if port == "" { + port = defaultMilestonePortForScheme(scheme) + } + + return strings.TrimSpace(parsedURL.Hostname()), port + } + + scheme := "http" + lowerValue := strings.ToLower(value) + + if strings.HasPrefix(lowerValue, "https://") { + scheme = "https" } value = strings.TrimPrefix(value, "http://") @@ -884,10 +1129,19 @@ func extractMilestoneHardwareAddressParts(value string) (string, string) { value = strings.TrimRight(value, "/") if host, port, found := strings.Cut(value, ":"); found { - return strings.TrimSpace(host), strings.TrimSpace(port) + port = strings.TrimSpace(port) + + if isOnlyDigits(port) { + return strings.TrimSpace(host), port + } } - return strings.TrimSpace(value), "" + return strings.TrimSpace(value), defaultMilestonePortForScheme(scheme) +} + +func extractMilestoneHardwarePort(value string) string { + _, port := extractMilestoneHardwareAddressParts(value) + return port } func (s *Server) requestMilestoneHardware( @@ -930,6 +1184,54 @@ func (s *Server) requestMilestoneHardware( return hardwareResponse.Array, nil } +func (s *Server) requestMilestoneHardwareDetails( + ctx context.Context, + host string, + hardwareID string, + accessToken string, + skipTLSVerify bool, +) (map[string]any, error) { + requestURL := strings.TrimRight(host, "/") + + "/API/rest/v1/hardware/" + + url.PathEscape(hardwareID) + + log.Printf( + "Requesting Milestone hardware details: url=%s skipTLSVerify=%t", + requestURL, + skipTLSVerify, + ) + + body, err := requestMilestoneJSON( + ctx, + requestURL, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + + var envelope milestoneHardwareUpdateEnvelope + if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil { + return envelope.Data, nil + } + + var direct map[string]any + if err := json.Unmarshal(body, &direct); err != nil { + return nil, fmt.Errorf( + "milestone hardware details response could not be parsed: body=%s error=%w", + truncateMilestoneLogBody(body), + err, + ) + } + + if data, ok := direct["data"].(map[string]any); ok { + return data, nil + } + + return direct, nil +} + func (s *Server) requestMilestoneHardwareDriverSettings( ctx context.Context, host string, diff --git a/backend/devices.go b/backend/devices.go index fed1ecc..15cabf5 100644 --- a/backend/devices.go +++ b/backend/devices.go @@ -14,29 +14,30 @@ import ( ) type Device struct { - ID string `json:"id"` - InventoryNumber string `json:"inventoryNumber"` - Manufacturer string `json:"manufacturer"` - Model string `json:"model"` - SerialNumber string `json:"serialNumber"` - MacAddress string `json:"macAddress"` - IPAddress string `json:"ipAddress"` - MilestonePort string `json:"milestonePort"` - Location string `json:"location"` - LoanStatus string `json:"loanStatus"` - IsBaoDevice bool `json:"isBaoDevice"` - Comment string `json:"comment"` - FirmwareVersion string `json:"firmwareVersion"` - FirmwareUpdate *DeviceFirmwareUpdate `json:"firmwareUpdate,omitempty"` - SupportBadges []DeviceSupportBadge `json:"supportBadges,omitempty"` - MilestoneRecordingServerID string `json:"milestoneRecordingServerId"` - MilestoneHardwareID string `json:"milestoneHardwareId"` - MilestoneDisplayName string `json:"milestoneDisplayName"` - MilestoneEnabled bool `json:"milestoneEnabled"` - MilestoneSyncedAt *time.Time `json:"milestoneSyncedAt,omitempty"` - RelatedDevices []DeviceShort `json:"relatedDevices"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + InventoryNumber string `json:"inventoryNumber"` + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` + SerialNumber string `json:"serialNumber"` + MacAddress string `json:"macAddress"` + IPAddress string `json:"ipAddress"` + MilestonePort string `json:"milestonePort"` + Location string `json:"location"` + LoanStatus string `json:"loanStatus"` + IsBaoDevice bool `json:"isBaoDevice"` + Comment string `json:"comment"` + FirmwareVersion string `json:"firmwareVersion"` + FirmwareUpdate *DeviceFirmwareUpdate `json:"firmwareUpdate,omitempty"` + SupportBadges []DeviceSupportBadge `json:"supportBadges,omitempty"` + MilestoneRecordingServerID string `json:"milestoneRecordingServerId"` + MilestoneHardwareID string `json:"milestoneHardwareId"` + MilestoneDisplayName string `json:"milestoneDisplayName"` + MilestoneEnabled bool `json:"milestoneEnabled"` + MilestoneSyncedAt *time.Time `json:"milestoneSyncedAt,omitempty"` + RelatedDevices []DeviceShort `json:"relatedDevices"` + MilestoneChildDevices []MilestoneHardwareChildDevice `json:"milestoneChildDevices"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type DeviceFirmwareUpdate struct { @@ -63,6 +64,19 @@ type DeviceShort struct { Model string `json:"model"` } +type MilestoneHardwareChildDevice struct { + ID string `json:"id"` + MilestoneHardwareID string `json:"milestoneHardwareId"` + MilestoneDeviceID string `json:"milestoneDeviceId"` + DeviceType string `json:"deviceType"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + ShortName string `json:"shortName"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + type CreateDeviceRequest struct { InventoryNumber string `json:"inventoryNumber"` Manufacturer string `json:"manufacturer"` @@ -137,7 +151,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { defer rows.Close() devices := make([]Device, 0) - deviceMap := map[string]*Device{} + deviceIndexMap := map[string]int{} for rows.Next() { var device Device @@ -169,9 +183,10 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { } device.RelatedDevices = []DeviceShort{} + device.MilestoneChildDevices = []MilestoneHardwareChildDevice{} devices = append(devices, device) - deviceMap[device.ID] = &devices[len(devices)-1] + deviceIndexMap[device.ID] = len(devices) - 1 } if err := rows.Err(); err != nil { @@ -216,9 +231,12 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { return } - device, exists := deviceMap[deviceID] + deviceIndex, exists := deviceIndexMap[deviceID] if exists { - device.RelatedDevices = append(device.RelatedDevices, relatedDevice) + devices[deviceIndex].RelatedDevices = append( + devices[deviceIndex].RelatedDevices, + relatedDevice, + ) } } @@ -227,6 +245,72 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { return } + childRows, err := s.db.Query( + r.Context(), + ` + SELECT + devices.id::TEXT, + 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.name, + milestone_hardware_child_devices.display_name, + milestone_hardware_child_devices.short_name, + milestone_hardware_child_devices.enabled, + milestone_hardware_child_devices.created_at, + milestone_hardware_child_devices.updated_at + FROM milestone_hardware_child_devices + INNER JOIN devices + ON devices.milestone_hardware_id = milestone_hardware_child_devices.milestone_hardware_id + WHERE COALESCE(devices.milestone_hardware_id, '') <> '' + ORDER BY + milestone_hardware_child_devices.device_type ASC, + milestone_hardware_child_devices.name ASC, + milestone_hardware_child_devices.display_name ASC + `, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not load Milestone child devices") + return + } + defer childRows.Close() + + for childRows.Next() { + var deviceID string + var childDevice MilestoneHardwareChildDevice + + if err := childRows.Scan( + &deviceID, + &childDevice.ID, + &childDevice.MilestoneHardwareID, + &childDevice.MilestoneDeviceID, + &childDevice.DeviceType, + &childDevice.Name, + &childDevice.DisplayName, + &childDevice.ShortName, + &childDevice.Enabled, + &childDevice.CreatedAt, + &childDevice.UpdatedAt, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Could not read Milestone child devices") + return + } + + deviceIndex, exists := deviceIndexMap[deviceID] + if exists { + devices[deviceIndex].MilestoneChildDevices = append( + devices[deviceIndex].MilestoneChildDevices, + childDevice, + ) + } + } + + if err := childRows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Could not read Milestone child devices") + return + } + s.enrichDevicesWithFirmwareUpdates(r.Context(), devices) writeJSON(w, http.StatusOK, map[string]any{ diff --git a/backend/milestone_api.go b/backend/milestone_api.go index fa35bf0..da2be1a 100644 --- a/backend/milestone_api.go +++ b/backend/milestone_api.go @@ -8,6 +8,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "io" "net/http" "net/url" @@ -46,6 +47,151 @@ type updateMilestoneHardwareRequest struct { 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, @@ -282,11 +428,13 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re return } - if err := patchMilestoneHardware( + if err := updateMilestoneHardware( r.Context(), config, device.MilestoneHardwareID, payload, + device.MilestoneDisplayName, + nextDisplayName, ); err != nil { s.logAndWriteUserError( w, @@ -294,7 +442,7 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re user, http.StatusBadGateway, "milestone_hardware", - "patch_hardware", + "update_hardware", "Milestone-Gerät konnte nicht aktualisiert werden", err, LogFields{ @@ -338,6 +486,41 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re 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") @@ -387,36 +570,59 @@ func normalizeMilestoneHardwareAddress(value string, port string) string { return "" } - if !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") { + 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() == "" { - value = strings.TrimRight(value, "/") - if port != "" && !strings.Contains(value, ":") { - value += ":" + port + 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) + } } - return value + "/" - } + if port == "" { + port = defaultMilestonePortForScheme("http") + } - scheme := strings.TrimSpace(parsedURL.Scheme) - if scheme == "" { - scheme = "http" + scheme := milestoneSchemeForPort(port) + + return scheme + "://" + host + ":" + port + "/" } host := parsedURL.Hostname() + if port == "" { - port = parsedURL.Port() + port = normalizeMilestonePort(parsedURL.Port()) } - hostPort := host - if port != "" { - hostPort += ":" + port + if port == "" { + port = defaultMilestonePortForScheme(parsedURL.Scheme) } - return scheme + "://" + hostPort + "/" + 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 { @@ -430,6 +636,10 @@ func normalizeMilestonePort(value string) string { return value } +type milestoneHardwareUpdateEnvelope struct { + Data map[string]any `json:"data"` +} + const milestoneTokenRefreshBeforeExpiry = 2 * time.Minute func (s *Server) getMilestoneRuntimeConfig( @@ -554,11 +764,162 @@ func (s *Server) refreshMilestoneRuntimeToken(ctx context.Context) error { return err } -func patchMilestoneHardware( +func updateMilestoneHardware( ctx context.Context, config milestoneRuntimeConfig, hardwareID string, - payload any, + 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 { @@ -569,6 +930,482 @@ func patchMilestoneHardware( "/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, @@ -598,13 +1435,17 @@ func patchMilestoneHardware( if response.StatusCode < 200 || response.StatusCode >= 300 { return newAppErrorf( - "milestone hardware patch failed", + "milestone child device patch failed", LogFields{ "statusCode": response.StatusCode, "responseBody": string(responseBody), - "hardwareId": hardwareID, + "collection": collection, + "deviceId": deviceID, + "payload": payload, }, - "milestone hardware patch failed: status=%d body=%s", + "milestone child device patch failed: collection=%s id=%s status=%d body=%s", + collection, + deviceID, response.StatusCode, string(responseBody), ) diff --git a/backend/milestone_devices.go b/backend/milestone_devices.go index 6d4c224..3fb6c71 100644 --- a/backend/milestone_devices.go +++ b/backend/milestone_devices.go @@ -50,6 +50,10 @@ func syncMilestoneHardwareDevices( return syncedCount, err } + if err := syncMilestoneHardwareChildDevices(ctx, tx, hardwareDevice); err != nil { + return syncedCount, err + } + if synced { syncedCount++ } @@ -58,6 +62,165 @@ func syncMilestoneHardwareDevices( return syncedCount, nil } +func syncMilestoneHardwareChildDevices( + ctx context.Context, + tx pgx.Tx, + hardwareDevice milestoneHardwareDevice, +) error { + hardwareID := strings.TrimSpace(hardwareDevice.HardwareID) + if hardwareID == "" { + return nil + } + + _, err := tx.Exec( + ctx, + ` + DELETE FROM milestone_hardware_child_devices + WHERE milestone_hardware_id = $1 + `, + hardwareID, + ) + if err != nil { + return err + } + + for _, camera := range hardwareDevice.Cameras { + if err := upsertMilestoneHardwareChildDevice( + ctx, + tx, + hardwareID, + "camera", + camera, + ); err != nil { + return err + } + } + + for _, microphone := range hardwareDevice.Microphones { + if err := upsertMilestoneHardwareChildDevice( + ctx, + tx, + hardwareID, + "microphone", + microphone, + ); err != nil { + return err + } + } + + for _, input := range hardwareDevice.Inputs { + if err := upsertMilestoneHardwareChildDevice( + ctx, + tx, + hardwareID, + "input", + input, + ); err != nil { + return err + } + } + + for _, output := range hardwareDevice.Outputs { + if err := upsertMilestoneHardwareChildDevice( + ctx, + tx, + hardwareID, + "output", + output, + ); err != nil { + return err + } + } + + for _, speaker := range hardwareDevice.Speakers { + if err := upsertMilestoneHardwareChildDevice( + ctx, + tx, + hardwareID, + "speaker", + speaker, + ); err != nil { + return err + } + } + + for _, metadata := range hardwareDevice.Metadata { + if err := upsertMilestoneHardwareChildDevice( + ctx, + tx, + hardwareID, + "metadata", + metadata, + ); err != nil { + return err + } + } + + return nil +} + +func upsertMilestoneHardwareChildDevice( + ctx context.Context, + tx pgx.Tx, + hardwareID string, + deviceType string, + childDevice milestoneNamedDevice, +) error { + milestoneDeviceID := strings.TrimSpace(childDevice.ID) + if milestoneDeviceID == "" { + return nil + } + + name := strings.TrimSpace(childDevice.Name) + displayName := strings.TrimSpace(childDevice.DisplayName) + shortName := strings.TrimSpace(childDevice.ShortName) + + if name == "" { + name = displayName + } + if name == "" { + name = shortName + } + if displayName == "" { + displayName = name + } + if shortName == "" { + shortName = name + } + + _, err := tx.Exec( + ctx, + ` + INSERT INTO milestone_hardware_child_devices ( + milestone_hardware_id, + milestone_device_id, + device_type, + name, + display_name, + short_name, + enabled + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (milestone_hardware_id, milestone_device_id, device_type) + DO UPDATE SET + name = EXCLUDED.name, + display_name = EXCLUDED.display_name, + short_name = EXCLUDED.short_name, + enabled = EXCLUDED.enabled, + updated_at = now() + `, + hardwareID, + milestoneDeviceID, + deviceType, + name, + displayName, + shortName, + childDevice.Enabled, + ) + + return err +} + func createOrUpdateMilestoneHardwareDevice( ctx context.Context, tx pgx.Tx, diff --git a/backend/routes.go b/backend/routes.go index a6d418c..f66cfb1 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -35,6 +35,10 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("PUT /devices/{id}/history/{historyId}", s.requireAuth(s.handleUpdateDeviceJournalEntry)) mux.HandleFunc("PATCH /devices/{id}/milestone", s.requireAuth(s.handleUpdateMilestoneHardware)) + mux.HandleFunc( + "PATCH /devices/{id}/milestone-child/{childId}", + s.requireAuth(s.handleUpdateMilestoneChildDevice), + ) mux.HandleFunc("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState)) mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware)) diff --git a/backend/setup/main.go b/backend/setup/main.go index b1c353d..aa1c376 100644 --- a/backend/setup/main.go +++ b/backend/setup/main.go @@ -347,6 +347,25 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { ) `, + ` + CREATE TABLE IF NOT EXISTS milestone_hardware_child_devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + milestone_hardware_id TEXT NOT NULL, + milestone_device_id TEXT NOT NULL, + device_type TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL DEFAULT '', + short_name TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT true, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE (milestone_hardware_id, milestone_device_id, device_type) + ) + `, + ` CREATE TABLE IF NOT EXISTS device_manufacturers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -586,7 +605,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { `CREATE INDEX IF NOT EXISTS idx_dashboard_settings_user_id ON dashboard_settings(user_id)`, - `ALTER TABLE devices ADD COLUMN IF NOT EXISTS milestone_port TEXT NOT NULL DEFAULT ''`, + `CREATE INDEX IF NOT EXISTS idx_milestone_child_devices_hardware_id ON milestone_hardware_child_devices(milestone_hardware_id)`, + `CREATE INDEX IF NOT EXISTS idx_milestone_child_devices_device_id ON milestone_hardware_child_devices(milestone_device_id)`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`, diff --git a/frontend/src/components/types.ts b/frontend/src/components/types.ts index b1c9e1d..2cc3a45 100644 --- a/frontend/src/components/types.ts +++ b/frontend/src/components/types.ts @@ -65,8 +65,10 @@ export type Device = { milestoneRecordingServerId?: string milestoneHardwareId?: string milestoneDisplayName: string + milestonePort?: string milestoneEnabled?: boolean milestoneSyncedAt?: string + milestoneChildDevices?: MilestoneHardwareChildDevice[] relatedDevices: RelatedDevice[] isBaoDevice: boolean } @@ -88,6 +90,17 @@ export type DeviceSupportBadge = { supportUrl: string } +export type MilestoneHardwareChildDevice = { + id: string + milestoneHardwareId: string + milestoneDeviceId: string + deviceType: 'camera' | 'microphone' | string + name: string + displayName: string + shortName: string + enabled: boolean +} + export type Operation = { id: string operationNumber: string diff --git a/frontend/src/pages/devices/ChildDeviceCard.tsx b/frontend/src/pages/devices/ChildDeviceCard.tsx new file mode 100644 index 0000000..1cadccc --- /dev/null +++ b/frontend/src/pages/devices/ChildDeviceCard.tsx @@ -0,0 +1,112 @@ +// frontend\src\pages\devices\ChildDeviceCard.tsx + +import Switch from '../../components/Switch' +import type { Device } from '../../components/types' +import { + ArrowDownTrayIcon, + ArrowUpTrayIcon, + CircleStackIcon, + MicrophoneIcon, + SpeakerWaveIcon, + VideoCameraIcon, +} from '@heroicons/react/20/solid' + +type MilestoneChildDevice = NonNullable[number] + +type ChildDeviceCardProps = { + item: MilestoneChildDevice + isBusy?: boolean + onToggle?: ( + childDevice: MilestoneChildDevice, + enabled: boolean, + ) => void | Promise +} + +function getChildDevicePresentation(deviceType: string) { + const normalizedType = deviceType.toLowerCase() + + if (normalizedType === 'microphone' || normalizedType === 'microphones') { + return { + Icon: MicrophoneIcon, + typeLabel: 'Mikrofon', + } + } + + if (normalizedType === 'input' || normalizedType === 'inputs') { + return { + Icon: ArrowDownTrayIcon, + typeLabel: 'Eingang', + } + } + + if (normalizedType === 'output' || normalizedType === 'outputs') { + return { + Icon: ArrowUpTrayIcon, + typeLabel: 'Ausgang', + } + } + + if (normalizedType === 'speaker' || normalizedType === 'speakers') { + return { + Icon: SpeakerWaveIcon, + typeLabel: 'Lautsprecher', + } + } + + if (normalizedType === 'metadata') { + return { + Icon: CircleStackIcon, + typeLabel: 'Metadaten', + } + } + + return { + Icon: VideoCameraIcon, + typeLabel: 'Kamera', + } +} + +export default function ChildDeviceCard({ + item, + isBusy = false, + onToggle, +}: ChildDeviceCardProps) { + const itemTitle = + item.displayName || item.name || item.shortName || 'Unbenanntes Gerät' + + const isEnabled = item.enabled ?? true + const { Icon, typeLabel } = getChildDevicePresentation(item.deviceType) + + return ( +
+
+
+
+
+ +
+

+ {itemTitle} +

+ +

+ {typeLabel} +

+
+
+ +
+ void onToggle?.(item, event.target.checked)} + label="" + className="scale-90" + /> +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/devices/DeviceFormFields.tsx b/frontend/src/pages/devices/DeviceFormFields.tsx index e160377..8f5732a 100644 --- a/frontend/src/pages/devices/DeviceFormFields.tsx +++ b/frontend/src/pages/devices/DeviceFormFields.tsx @@ -6,8 +6,16 @@ import Checkbox from '../../components/Checkbox' import Switch from '../../components/Switch' import type { DeviceModalTabId } from './DeviceModalTabs' import Textarea from '../../components/Textarea' +import ChildDeviceCard from './ChildDeviceCard' import type { Device } from '../../components/types' -import { VideoCameraIcon } from '@heroicons/react/20/solid' +import { + ArrowDownTrayIcon, + ArrowUpTrayIcon, + CircleStackIcon, + MicrophoneIcon, + SpeakerWaveIcon, + VideoCameraIcon, +} from '@heroicons/react/20/solid' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' @@ -18,6 +26,11 @@ type DeviceFormFieldsProps = { devices: Device[] relatedDeviceIds: string[] onToggleRelatedDevice: (deviceId: string) => void + togglingMilestoneChildDeviceIds?: Set + onToggleMilestoneChildDevice?: ( + childDevice: NonNullable[number], + enabled: boolean, + ) => void | Promise } export const inputClassName = @@ -55,6 +68,62 @@ export function SectionHeader({ ) } +function MilestoneChildDeviceCards({ + title, + emptyText, + items, + togglingIds, + onToggle, + icon: Icon, +}: { + title: string + emptyText: string + items: NonNullable + togglingIds: Set + onToggle?: ( + childDevice: NonNullable[number], + enabled: boolean, + ) => void | Promise + icon: typeof VideoCameraIcon +}) { + return ( +
+
+
+
+ + + {items.length} + +
+ + {items.length === 0 ? ( +

+ {emptyText} +

+ ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ) +} + function formatMacAddress(value: string) { const hexValue = value .replace(/[^0-9a-fA-F]/g, '') @@ -166,6 +235,8 @@ export default function DeviceFormFields({ devices, relatedDeviceIds, onToggleRelatedDevice, + togglingMilestoneChildDeviceIds, + onToggleMilestoneChildDevice, }: DeviceFormFieldsProps) { const assignableDevices = useMemo( () => devices.filter((item) => item.id !== device?.id), @@ -234,6 +305,32 @@ export default function DeviceFormFields({ device?.milestoneHardwareId || device?.milestoneRecordingServerId, ) + const milestoneChildDevices = device?.milestoneChildDevices ?? [] + + const milestoneCameras = milestoneChildDevices.filter( + (item) => item.deviceType === 'camera' || item.deviceType === 'cameras', + ) + + const milestoneMicrophones = milestoneChildDevices.filter( + (item) => item.deviceType === 'microphone' || item.deviceType === 'microphones', + ) + + const milestoneInputs = milestoneChildDevices.filter( + (item) => item.deviceType === 'input' || item.deviceType === 'inputs', + ) + + const milestoneOutputs = milestoneChildDevices.filter( + (item) => item.deviceType === 'output' || item.deviceType === 'outputs', + ) + + const milestoneSpeakers = milestoneChildDevices.filter( + (item) => item.deviceType === 'speaker' || item.deviceType === 'speakers', + ) + + const milestoneMetadata = milestoneChildDevices.filter( + (item) => item.deviceType === 'metadata', + ) + const milestoneReadonlyClassName = '!cursor-not-allowed !bg-gray-50 !text-gray-500 !outline-gray-200 dark:!bg-white/10 dark:!text-gray-400 dark:!outline-white/10' @@ -384,92 +481,6 @@ export default function DeviceFormFields({ - {isMilestoneDevice && ( - <> - - -
- - -
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- - )} -
-
- + {!isMilestoneDevice && ( + <> +
+ -
- {isMilestoneDevice && ( -
-
- -
- - -
- {isMilestoneDevice && ( -
-
- - {isMilestoneDevice && ( -
- - -
-
-
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+ )} -
- + {isMilestoneDevice && ( +
+ -
- {isMilestoneDevice && ( -
+ +

+ Diese Felder werden mit Milestone synchronisiert. +

+
+ +
+
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ + )}
@@ -796,6 +958,78 @@ export default function DeviceFormFields({ )} + + ) } \ No newline at end of file diff --git a/frontend/src/pages/devices/DeviceModalTabs.tsx b/frontend/src/pages/devices/DeviceModalTabs.tsx index 5e8be66..1d27d08 100644 --- a/frontend/src/pages/devices/DeviceModalTabs.tsx +++ b/frontend/src/pages/devices/DeviceModalTabs.tsx @@ -2,7 +2,12 @@ import { type ComponentType, type SVGProps } from 'react' -export type DeviceModalTabId = 'masterData' | 'location' | 'accessories' | 'history' +export type DeviceModalTabId = + | 'masterData' + | 'location' + | 'accessories' + | 'hardware' + | 'history' type DeviceModalTabIcon = ComponentType> diff --git a/frontend/src/pages/devices/DevicesPage.tsx b/frontend/src/pages/devices/DevicesPage.tsx index 80abe11..f1bfe86 100644 --- a/frontend/src/pages/devices/DevicesPage.tsx +++ b/frontend/src/pages/devices/DevicesPage.tsx @@ -184,6 +184,8 @@ export default function DevicesPage() { const [firmwareCheckNow, setFirmwareCheckNow] = useState(() => Date.now()) const closeCleanupTimeoutRef = useRef(null) + const [togglingMilestoneChildDeviceIds, setTogglingMilestoneChildDeviceIds] = useState>(() => new Set()) + useEffect(() => { loadDevices() void loadFirmwareCheckState() @@ -205,6 +207,18 @@ export default function DevicesPage() { } }, []) + useEffect(() => { + setEditingDevice((currentDevice) => { + if (!currentDevice) { + return currentDevice + } + + const nextDevice = devices.find((device) => device.id === currentDevice.id) + + return nextDevice ?? currentDevice + }) + }, [devices]) + useEffect(() => { function handleDeviceNotification(event: Event) { void loadDevices({ silent: true }) @@ -252,6 +266,56 @@ export default function DevicesPage() { } } + async function handleToggleMilestoneChildDevice( + device: Device, + childDevice: NonNullable[number], + enabled: boolean, + ) { + setTogglingMilestoneChildDeviceIds((currentIds) => { + const nextIds = new Set(currentIds) + nextIds.add(childDevice.id) + return nextIds + }) + + try { + const response = await fetch( + `${API_URL}/devices/${device.id}/milestone-child/${childDevice.id}`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ enabled }), + }, + ) + + if (!response.ok) { + throw new Error(await readApiError(response, 'Milestone-Child-Device konnte nicht aktualisiert werden')) + } + + await loadDevices({ silent: true }) + + showToast({ + variant: 'success', + title: enabled ? 'Gerät aktiviert' : 'Gerät deaktiviert', + message: `${childDevice.displayName || childDevice.name || childDevice.milestoneDeviceId} wurde aktualisiert.`, + }) + } catch (error) { + showErrorToast({ + title: 'Aktualisierung fehlgeschlagen', + error, + fallback: 'Milestone-Child-Device konnte nicht aktualisiert werden', + }) + } finally { + setTogglingMilestoneChildDeviceIds((currentIds) => { + const nextIds = new Set(currentIds) + nextIds.delete(childDevice.id) + return nextIds + }) + } + } + async function handleCheckFirmware() { setIsCheckingFirmware(true) @@ -885,7 +949,7 @@ export default function DevicesPage() { type="button" disabled={isToggling} onClick={(event) => handleToggleMilestoneDeviceStatus(device, event)} - className="inline-flex min-w-28 cursor-pointer justify-center rounded-md transition disabled:cursor-wait disabled:opacity-60" + className="inline-flex min-w-24 cursor-pointer justify-center rounded-md transition disabled:cursor-wait disabled:opacity-60" title={ isToggling ? 'Milestone-Status wird geändert...' @@ -1029,6 +1093,14 @@ export default function DevicesPage() { isJournalSubmitting={isJournalSubmitting} onJournalEntrySubmit={handleCreateDeviceJournalEntry} onJournalEntryUpdate={handleUpdateDeviceJournalEntry} + togglingMilestoneChildDeviceIds={togglingMilestoneChildDeviceIds} + onToggleMilestoneChildDevice={(childDevice, enabled) => { + if (!editingDevice) { + return + } + + return handleToggleMilestoneChildDevice(editingDevice, childDevice, enabled) + }} /> diff --git a/frontend/src/pages/devices/EditDeviceModal.tsx b/frontend/src/pages/devices/EditDeviceModal.tsx index 8dfb66a..c659dad 100644 --- a/frontend/src/pages/devices/EditDeviceModal.tsx +++ b/frontend/src/pages/devices/EditDeviceModal.tsx @@ -44,6 +44,11 @@ type EditDeviceModalProps = { isJournalSubmitting?: boolean onJournalEntrySubmit?: (content: string) => void | Promise onJournalEntryUpdate?: (itemId: string | number, content: string) => void | Promise + togglingMilestoneChildDeviceIds?: Set + onToggleMilestoneChildDevice?: ( + childDevice: NonNullable[number], + enabled: boolean, + ) => void | Promise } type JournalItems = ComponentProps['items'] @@ -64,6 +69,11 @@ const editDeviceTabs = [ name: 'Zubehör', icon: WrenchScrewdriverIcon, }, + { + id: 'hardware' as const, + name: 'Hardware', + icon: VideoCameraIcon, + }, { id: 'history' as const, name: 'Journal', @@ -132,6 +142,8 @@ export default function EditDeviceModal({ isJournalSubmitting = false, onJournalEntrySubmit, onJournalEntryUpdate, + togglingMilestoneChildDeviceIds, + onToggleMilestoneChildDevice, }: EditDeviceModalProps) { const [activeTab, setActiveTab] = useState('masterData') @@ -239,10 +251,6 @@ export default function EditDeviceModal({
-

- Bearbeite Gerätedaten, Standort, Verleih-Status und Zubehör. -

- {device?.milestoneHardwareId && (