// backend\milestone_sync.go package main import ( "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "net/url" "strings" "time" "github.com/jackc/pgx/v5" ) type milestoneSyncResult struct { TokenReceived bool `json:"tokenReceived"` TokenType string `json:"tokenType"` TokenExpiresAt time.Time `json:"tokenExpiresAt"` RecordingServerID string `json:"recordingServerId"` RecordingServerName string `json:"recordingServerName"` RecordingServerVersion string `json:"recordingServerVersion"` HardwareCount int `json:"hardwareCount"` } type milestoneSyncEvent struct { Type string `json:"type"` Message string `json:"message,omitempty"` TokenType string `json:"tokenType,omitempty"` TokenExpiresAt *time.Time `json:"tokenExpiresAt,omitempty"` RecordingServerID string `json:"recordingServerId,omitempty"` RecordingServerName string `json:"recordingServerName,omitempty"` RecordingServerVersion string `json:"recordingServerVersion,omitempty"` HardwareCount int `json:"hardwareCount,omitempty"` Settings *milestoneSettingsResponse `json:"settings,omitempty"` } type milestoneRecordingServersResponse struct { Array []milestoneRecordingServer `json:"array"` } type milestoneRecordingServer struct { ID string `json:"id"` Name string `json:"name"` DisplayName string `json:"displayName"` Version string `json:"version"` ProductVersion string `json:"productVersion"` RecordingServerVersion string `json:"recordingServerVersion"` } type milestoneHardwareResponse struct { Array []milestoneHardware `json:"array"` } type milestoneHardware struct { ID string `json:"id"` Name string `json:"name"` DisplayName string `json:"displayName"` DeviceName string `json:"deviceName"` Address string `json:"address"` Enabled *bool `json:"enabled"` Disabled *bool `json:"disabled"` Relations milestoneRelations `json:"relations"` } type milestoneRelations struct { Parent milestoneRelationRef `json:"parent"` Self milestoneRelationRef `json:"self"` } type milestoneRelationRef struct { Type string `json:"type"` ID string `json:"id"` } type milestoneHardwareDriverSettingsResponse struct { Array []milestoneHardwareDriverSettingsItem `json:"array"` } type milestoneHardwareDriverSettingsItem struct { DisplayName string `json:"displayName"` HardwareDriverSettings milestoneHardwareDriverSettings `json:"hardwareDriverSettings"` Relations milestoneRelations `json:"relations"` } type milestoneHardwareDriverSettings struct { DetectedModelName string `json:"detectedModelName"` ProductID string `json:"productID"` MacAddress string `json:"macAddress"` IPAddress string `json:"ipAddress"` Address string `json:"address"` HostName string `json:"hostName"` DeviceModelName string `json:"deviceModelName"` DeviceManufacturer string `json:"deviceManufacturer"` FirmwareVersion string `json:"firmwareVersion"` SerialNumber string `json:"serialNumber"` } type milestoneHardwareDevice struct { HardwareID string RecordingServerID string DisplayName string DeviceName string Manufacturer string MacAddress string IPAddress string Port string DeviceModelName string SerialNumber string FirmwareVersion string Enabled bool Cameras []milestoneNamedDevice Microphones []milestoneNamedDevice } func writeMilestoneSyncEvent(w http.ResponseWriter, event milestoneSyncEvent) { if err := json.NewEncoder(w).Encode(event); err != nil { log.Printf("Milestone sync stream event could not be written: %v", err) return } if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } } func emitMilestoneSyncEvent( progress func(milestoneSyncEvent), event milestoneSyncEvent, ) { if progress != nil { progress(event) } } func (s *Server) refreshMilestoneTokenAndServerID( ctx context.Context, credentials milestoneCredentials, ) (milestoneSyncResult, error) { return s.refreshMilestoneTokenAndServerIDWithProgress(ctx, credentials, nil) } func (s *Server) refreshMilestoneTokenAndServerIDWithProgress( ctx context.Context, credentials milestoneCredentials, progress func(milestoneSyncEvent), ) (milestoneSyncResult, error) { var syncResult milestoneSyncResult tokenResponse, err := s.requestMilestoneToken(ctx, credentials) if err != nil { return syncResult, err } accessToken := strings.TrimSpace(tokenResponse.AccessToken) if accessToken == "" { return syncResult, errors.New("milestone returned empty access token") } 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) syncResult.TokenReceived = true syncResult.TokenType = tokenType syncResult.TokenExpiresAt = tokenExpiresAt emitMilestoneSyncEvent(progress, milestoneSyncEvent{ Type: "token", TokenType: tokenType, TokenExpiresAt: &tokenExpiresAt, }) serverID, serverName, serverVersion, err := s.requestMilestoneServerID( ctx, credentials.Host, tokenType, accessToken, credentials.SkipTLSVerify, ) if err != nil { return syncResult, err } syncResult.RecordingServerID = serverID syncResult.RecordingServerName = serverName syncResult.RecordingServerVersion = serverVersion emitMilestoneSyncEvent(progress, milestoneSyncEvent{ Type: "recordingServer", RecordingServerID: serverID, RecordingServerName: serverName, RecordingServerVersion: serverVersion, }) hardwareDevices, err := s.requestMilestoneHardwareDevices( ctx, credentials.Host, serverID, accessToken, credentials.SkipTLSVerify, ) if err != nil { return syncResult, err } tx, err := s.db.Begin(ctx) if err != nil { return syncResult, err } defer tx.Rollback(ctx) encryptionKey := s.milestoneEncryptionKey() _, err = tx.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(), server_id = $5, server_name = $6, server_version = $7, server_updated_at = now(), updated_at = now() WHERE id = 1 `, accessToken, encryptionKey, tokenType, tokenExpiresAt, serverID, serverName, serverVersion, ) if err != nil { return syncResult, err } syncedHardwareCount, err := syncMilestoneHardwareDevices(ctx, tx, hardwareDevices) if err != nil { return syncResult, err } syncResult.HardwareCount = syncedHardwareCount if err := tx.Commit(ctx); err != nil { return syncResult, err } emitMilestoneSyncEvent(progress, milestoneSyncEvent{ Type: "hardware", HardwareCount: syncResult.HardwareCount, }) log.Printf( "Milestone sync completed successfully: recordingServerId=%s hardwareDevices=%d", serverID, syncedHardwareCount, ) return syncResult, nil } func (s *Server) requestMilestoneServerID( ctx context.Context, host string, tokenType string, accessToken string, skipTLSVerify bool, ) (string, string, string, error) { requestURL := strings.TrimRight(host, "/") + "/API/rest/v1/recordingServers" log.Printf( "Requesting Milestone recording server id: url=%s skipTLSVerify=%t", requestURL, skipTLSVerify, ) request, err := http.NewRequestWithContext( ctx, http.MethodGet, requestURL, nil, ) if err != nil { return "", "", "", err } if tokenType == "" { tokenType = "Bearer" } request.Header.Set("Authorization", tokenType+" "+accessToken) request.Header.Set("Accept", "application/json") client := milestoneHTTPClient(skipTLSVerify) response, err := client.Do(request) if err != nil { return "", "", "", err } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return "", "", "", err } if response.StatusCode < 200 || response.StatusCode >= 300 { return "", "", "", fmt.Errorf( "milestone recording servers request failed: url=%s status=%d body=%s", requestURL, response.StatusCode, truncateMilestoneLogBody(body), ) } var recordingServersResponse milestoneRecordingServersResponse if err := json.Unmarshal(body, &recordingServersResponse); err != nil { return "", "", "", fmt.Errorf( "milestone recording servers response could not be parsed: body=%s error=%w", truncateMilestoneLogBody(body), err, ) } if len(recordingServersResponse.Array) == 0 { return "", "", "", fmt.Errorf( "milestone returned no recording servers: body=%s", truncateMilestoneLogBody(body), ) } recordingServer := recordingServersResponse.Array[0] recordingServerID := strings.TrimSpace(recordingServer.ID) recordingServerName := strings.TrimSpace(recordingServer.DisplayName) if recordingServerName == "" { recordingServerName = strings.TrimSpace(recordingServer.Name) } recordingServerVersion := strings.TrimSpace(recordingServer.Version) if recordingServerVersion == "" { recordingServerVersion = strings.TrimSpace(recordingServer.ProductVersion) } if recordingServerVersion == "" { recordingServerVersion = strings.TrimSpace(recordingServer.RecordingServerVersion) } if recordingServerID == "" { return "", "", "", fmt.Errorf( "milestone returned recording server without id: body=%s", truncateMilestoneLogBody(body), ) } log.Printf( "Milestone recording server received successfully: id=%s displayName=%q version=%q", recordingServerID, recordingServerName, recordingServerVersion, ) return recordingServerID, recordingServerName, recordingServerVersion, nil } func (s *Server) requestMilestoneHardwareDevices( ctx context.Context, host string, recordingServerID string, accessToken string, skipTLSVerify bool, ) ([]milestoneHardwareDevice, error) { hardwareItems, err := s.requestMilestoneHardware( ctx, host, recordingServerID, accessToken, skipTLSVerify, ) if err != nil { return nil, err } result := make([]milestoneHardwareDevice, 0, len(hardwareItems)) for _, hardware := range hardwareItems { hardwareID := strings.TrimSpace(hardware.ID) if hardwareID == "" { hardwareID = strings.TrimSpace(hardware.Relations.Self.ID) } if hardwareID == "" { log.Printf("Milestone hardware skipped: missing hardware id") continue } settings, err := s.requestMilestoneHardwareDriverSettings( ctx, host, hardwareID, accessToken, skipTLSVerify, ) if err != nil { 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) } model := strings.TrimSpace(settings.DeviceModelName) if model == "" { model = strings.TrimSpace(settings.DetectedModelName) } manufacturer := normalizeMilestoneManufacturer( settings.DeviceManufacturer, model, ) if manufacturer == "" { manufacturer = normalizeMilestoneManufacturer( settings.ProductID, model, ) } if manufacturer == "" { manufacturer = extractMilestoneManufacturerFromName(model) } deviceName := strings.TrimSpace(hardware.DeviceName) if deviceName == "" { deviceName = displayName } enabled := true if hardware.Enabled != nil { enabled = *hardware.Enabled } if hardware.Disabled != nil { enabled = !*hardware.Disabled } hardwareAddress := strings.TrimSpace(milestoneStringValue(hardwareDetails["address"])) if hardwareAddress == "" { hardwareAddress = hardware.Address } ipAddress, port := extractMilestoneHardwareAddressParts(hardwareAddress) embeddedCameras := milestoneNamedDevicesFromHardwareCollection( hardwareDetails, "cameras", ) embeddedMicrophones := milestoneNamedDevicesFromHardwareCollection( hardwareDetails, "microphones", ) 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 } cameras := mergeMilestoneNamedDevices( embeddedCameras, camerasWithDisabled, ) microphones := mergeMilestoneNamedDevices( embeddedMicrophones, microphonesWithDisabled, ) result = append(result, milestoneHardwareDevice{ HardwareID: hardwareID, RecordingServerID: recordingServerID, DisplayName: displayName, DeviceName: deviceName, Manufacturer: manufacturer, MacAddress: strings.TrimSpace(settings.MacAddress), IPAddress: ipAddress, Port: port, DeviceModelName: model, SerialNumber: strings.TrimSpace(settings.SerialNumber), FirmwareVersion: strings.TrimSpace(settings.FirmwareVersion), Enabled: enabled, Cameras: cameras, Microphones: microphones, }) } cameraCount := 0 microphoneCount := 0 for _, hardwareDevice := range result { cameraCount += len(hardwareDevice.Cameras) microphoneCount += len(hardwareDevice.Microphones) } log.Printf( "Milestone hardware loaded successfully: recordingServerId=%s count=%d cameras=%d microphones=%d", recordingServerID, len(result), cameraCount, microphoneCount, ) 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 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) { value = strings.TrimSpace(value) if value == "" { return "", "" } parsedURL, err := url.Parse(value) if err == nil && parsedURL.Hostname() != "" { 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://") value = strings.TrimPrefix(value, "https://") value = strings.TrimRight(value, "/") if host, port, found := strings.Cut(value, ":"); found { port = strings.TrimSpace(port) if isOnlyDigits(port) { return strings.TrimSpace(host), port } } return strings.TrimSpace(value), defaultMilestonePortForScheme(scheme) } func extractMilestoneHardwarePort(value string) string { _, port := extractMilestoneHardwareAddressParts(value) return port } func (s *Server) requestMilestoneHardware( ctx context.Context, host string, recordingServerID string, accessToken string, skipTLSVerify bool, ) ([]milestoneHardware, error) { requestURL := strings.TrimRight(host, "/") + "/API/rest/v1/recordingServers/" + url.PathEscape(recordingServerID) + "/hardware?disabled" log.Printf( "Requesting Milestone hardware: url=%s skipTLSVerify=%t", requestURL, skipTLSVerify, ) body, err := requestMilestoneJSON( ctx, requestURL, accessToken, skipTLSVerify, ) if err != nil { return nil, err } var hardwareResponse milestoneHardwareResponse if err := json.Unmarshal(body, &hardwareResponse); err != nil { return nil, fmt.Errorf( "milestone hardware response could not be parsed: body=%s error=%w", truncateMilestoneLogBody(body), err, ) } 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, hardwareID string, accessToken string, skipTLSVerify bool, ) (milestoneHardwareDriverSettings, error) { requestURL := strings.TrimRight(host, "/") + "/API/rest/v1/hardware/" + url.PathEscape(hardwareID) + "/hardwareDriverSettings" log.Printf( "Requesting Milestone hardware driver settings: url=%s skipTLSVerify=%t", requestURL, skipTLSVerify, ) body, err := requestMilestoneJSON( ctx, requestURL, accessToken, skipTLSVerify, ) if err != nil { return milestoneHardwareDriverSettings{}, err } var settingsResponse milestoneHardwareDriverSettingsResponse if err := json.Unmarshal(body, &settingsResponse); err != nil { return milestoneHardwareDriverSettings{}, fmt.Errorf( "milestone hardware driver settings response could not be parsed: body=%s error=%w", truncateMilestoneLogBody(body), err, ) } if len(settingsResponse.Array) == 0 { return milestoneHardwareDriverSettings{}, fmt.Errorf( "milestone returned no hardware driver settings: hardwareId=%s body=%s", hardwareID, truncateMilestoneLogBody(body), ) } return settingsResponse.Array[0].HardwareDriverSettings, nil } func requestMilestoneJSON( ctx context.Context, requestURL string, accessToken string, skipTLSVerify bool, ) ([]byte, error) { request, err := http.NewRequestWithContext( ctx, http.MethodGet, requestURL, nil, ) if err != nil { return nil, err } request.Header.Set("Authorization", "Bearer "+accessToken) request.Header.Set("Accept", "application/json") client := milestoneHTTPClient(skipTLSVerify) response, err := client.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 request failed: url=%s status=%d body=%s", requestURL, response.StatusCode, truncateMilestoneLogBody(body), ) } return body, nil } func syncMilestoneHardwareDevices( ctx context.Context, tx pgx.Tx, hardwareDevices []milestoneHardwareDevice, ) (int, error) { syncedCount := 0 for _, hardwareDevice := range hardwareDevices { hardwareDevice.HardwareID = strings.TrimSpace(hardwareDevice.HardwareID) hardwareDevice.RecordingServerID = strings.TrimSpace(hardwareDevice.RecordingServerID) hardwareDevice.DisplayName = strings.TrimSpace(hardwareDevice.DisplayName) hardwareDevice.DeviceName = strings.TrimSpace(hardwareDevice.DeviceName) hardwareDevice.Manufacturer = strings.TrimSpace(hardwareDevice.Manufacturer) hardwareDevice.DeviceModelName = strings.TrimSpace(hardwareDevice.DeviceModelName) hardwareDevice.SerialNumber = strings.TrimSpace(hardwareDevice.SerialNumber) hardwareDevice.MacAddress = strings.TrimSpace(hardwareDevice.MacAddress) hardwareDevice.IPAddress = strings.TrimSpace(hardwareDevice.IPAddress) hardwareDevice.Port = strings.TrimSpace(hardwareDevice.Port) hardwareDevice.FirmwareVersion = strings.TrimSpace(hardwareDevice.FirmwareVersion) if hardwareDevice.HardwareID == "" { continue } if hardwareDevice.Manufacturer != "" { if err := ensureOptionalDeviceLookupValue( ctx, tx, "device_manufacturers", hardwareDevice.Manufacturer, ); err != nil { return syncedCount, err } } synced, err := createOrUpdateMilestoneHardwareDevice(ctx, tx, hardwareDevice) if err != nil { return syncedCount, err } if err := syncMilestoneHardwareChildDevices(ctx, tx, hardwareDevice); err != nil { return syncedCount, err } if synced { syncedCount++ } } 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 } } 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, recording_enabled, recording_storage_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 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, recording_enabled = EXCLUDED.recording_enabled, recording_storage_id = EXCLUDED.recording_storage_id, updated_at = now() `, hardwareID, milestoneDeviceID, deviceType, name, displayName, shortName, childDevice.Enabled, childDevice.RecordingEnabled, strings.TrimSpace(childDevice.RecordingStorageID), ) return err } func createOrUpdateMilestoneHardwareDevice( ctx context.Context, tx pgx.Tx, hardwareDevice milestoneHardwareDevice, ) (bool, error) { inventoryNumber, err := buildMilestoneInventoryNumber(ctx, tx, hardwareDevice) if err != nil { return false, err } commandTag, err := tx.Exec( ctx, ` INSERT INTO devices ( inventory_number, manufacturer, model, serial_number, mac_address, ip_address, milestone_port, location, loan_status, is_bao_device, comment, firmware_version, milestone_recording_server_id, milestone_hardware_id, milestone_display_name, milestone_enabled, milestone_synced_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, '', 'Verfügbar', false, '', $8, $9, $10, $11, $12, now() ) ON CONFLICT (milestone_hardware_id) WHERE milestone_hardware_id <> '' DO UPDATE SET inventory_number = EXCLUDED.inventory_number, manufacturer = EXCLUDED.manufacturer, model = EXCLUDED.model, serial_number = EXCLUDED.serial_number, mac_address = EXCLUDED.mac_address, ip_address = EXCLUDED.ip_address, milestone_port = EXCLUDED.milestone_port, firmware_version = EXCLUDED.firmware_version, milestone_recording_server_id = EXCLUDED.milestone_recording_server_id, milestone_display_name = EXCLUDED.milestone_display_name, milestone_enabled = EXCLUDED.milestone_enabled, milestone_synced_at = now(), updated_at = now() `, inventoryNumber, hardwareDevice.Manufacturer, hardwareDevice.DeviceModelName, hardwareDevice.SerialNumber, hardwareDevice.MacAddress, hardwareDevice.IPAddress, hardwareDevice.Port, hardwareDevice.FirmwareVersion, hardwareDevice.RecordingServerID, hardwareDevice.HardwareID, hardwareDevice.DisplayName, hardwareDevice.Enabled, ) if err != nil { return false, err } return commandTag.RowsAffected() > 0, nil }