diff --git a/Facett/BLEConnectionHandler.swift b/Facett/BLEConnectionHandler.swift index 1d25c8a..ee592bf 100644 --- a/Facett/BLEConnectionHandler.swift +++ b/Facett/BLEConnectionHandler.swift @@ -36,6 +36,8 @@ class BLEConnectionHandler { // UI updates must happen on main thread DispatchQueue.main.async { + let wasEmpty = bleManager.connectedGoPros.isEmpty + bleManager.connectedGoPros[uuid] = gopro // Move to connected list bleManager.connectingGoPros.removeValue(forKey: uuid) // Remove from connecting list bleManager.discoveredGoPros.removeValue(forKey: uuid) // Remove from discovered list @@ -43,6 +45,10 @@ class BLEConnectionHandler { // Reset initialization flag for new connection gopro.hasReceivedInitialStatus = false + if wasEmpty { + bleManager.startKeepAliveTimer() + } + // Notify that camera is connected bleManager.onCameraConnected?(uuid) } @@ -55,31 +61,39 @@ class BLEConnectionHandler { guard let bleManager = bleManager else { return } let uuid = peripheral.identifier - ErrorHandler.info("\(CameraIdentityManager.shared.getDisplayName(for: uuid, currentName: peripheral.name)) disconnected.") + let cameraName = CameraIdentityManager.shared.getDisplayName(for: uuid, currentName: peripheral.name) + ErrorHandler.info("\(cameraName) disconnected.") - // Cancel any pending connection retry timers bleManager.connectionManager.cancelConnectionRetry(for: uuid) - bleManager.cleanupDeviceState(for: uuid) DispatchQueue.main.async { let isSleeping = bleManager.isDeviceSleeping(uuid) + let wasConnected = bleManager.connectedGoPros[uuid] != nil if let gopro = bleManager.connectedGoPros[uuid] { bleManager.connectedGoPros.removeValue(forKey: uuid) if !isSleeping { bleManager.discoveredGoPros[uuid] = gopro } else { - ErrorHandler.info("\(CameraIdentityManager.shared.getDisplayName(for: uuid, currentName: peripheral.name)) is sleeping - not moving to discovered list") + ErrorHandler.debug("\(cameraName) is sleeping - not moving to discovered list") } } else if let gopro = bleManager.connectingGoPros[uuid] { bleManager.connectingGoPros.removeValue(forKey: uuid) if !isSleeping { bleManager.discoveredGoPros[uuid] = gopro } else { - ErrorHandler.info("\(CameraIdentityManager.shared.getDisplayName(for: uuid, currentName: peripheral.name)) is sleeping - not moving to discovered list") + ErrorHandler.debug("\(cameraName) is sleeping - not moving to discovered list") } } + + if bleManager.connectedGoPros.isEmpty { + bleManager.stopKeepAliveTimer() + } + + if wasConnected && !isSleeping { + bleManager.scheduleReconnectIfNeeded(for: uuid) + } } } @@ -104,7 +118,7 @@ class BLEConnectionHandler { // Iterate through discovered services for service in services { if service.uuid == BLEManager.Constants.UUIDs.goproService { - ErrorHandler.info("Discovered GoPro service for \(peripheral.name ?? "a device")") + ErrorHandler.debug("Discovered GoPro service for \(peripheral.name ?? "a device")") // Discover characteristics for the GoPro service peripheral.discoverCharacteristics( @@ -119,7 +133,7 @@ class BLEConnectionHandler { for: service ) } else if service.uuid == BLEManager.Constants.UUIDs.goproWiFiService { - ErrorHandler.info("Discovered GoPro WiFi Access Point service for \(peripheral.name ?? "a device")") + ErrorHandler.debug("Discovered GoPro WiFi Access Point service for \(peripheral.name ?? "a device")") // Discover characteristics for the GoPro WiFi service peripheral.discoverCharacteristics( @@ -152,75 +166,71 @@ class BLEConnectionHandler { return } + let deviceName = peripheral.name ?? "a device" + var discoveredNames: [String] = [] + for characteristic in characteristics { switch characteristic.uuid { case BLEManager.Constants.UUIDs.query: - ErrorHandler.info("Discovered 'Query' characteristic for \(peripheral.name ?? "a device")") + discoveredNames.append("Query") case BLEManager.Constants.UUIDs.queryResponse: - ErrorHandler.info("Discovered 'Query Response' characteristic for \(peripheral.name ?? "a device")") + discoveredNames.append("Query Response") if characteristic.properties.contains(.notify) { - peripheral.setNotifyValue(true, for: characteristic) // Enable notifications - ErrorHandler.info("Subscribed to notifications for 'Query Response'") + peripheral.setNotifyValue(true, for: characteristic) } case BLEManager.Constants.UUIDs.command: - ErrorHandler.info("Discovered 'Command' characteristic for \(peripheral.name ?? "a device")") + discoveredNames.append("Command") case BLEManager.Constants.UUIDs.commandResponse: - ErrorHandler.info("Discovered 'Command Response' characteristic for \(peripheral.name ?? "a device")") + discoveredNames.append("Command Response") if characteristic.properties.contains(.notify) { - peripheral.setNotifyValue(true, for: characteristic) // Enable notifications - ErrorHandler.info("Subscribed to notifications for 'Command Response'") + peripheral.setNotifyValue(true, for: characteristic) } case BLEManager.Constants.UUIDs.settings: - ErrorHandler.info("Discovered 'Settings' characteristic for \(peripheral.name ?? "a device")") + discoveredNames.append("Settings") case BLEManager.Constants.UUIDs.settingsResponse: - ErrorHandler.info("Discovered 'Settings Response' characteristic for \(peripheral.name ?? "a device")") + discoveredNames.append("Settings Response") if characteristic.properties.contains(.notify) { - peripheral.setNotifyValue(true, for: characteristic) // Enable notifications - ErrorHandler.info("Subscribed to notifications for 'Settings Response'") + peripheral.setNotifyValue(true, for: characteristic) } - // GoPro WiFi Access Point characteristics case BLEManager.Constants.UUIDs.wifiAPSSID: - ErrorHandler.info("Discovered 'WiFi AP SSID' characteristic for \(peripheral.name ?? "a device")") + discoveredNames.append("WiFi SSID") if characteristic.properties.contains(.read) { - // Read the current WiFi SSID peripheral.readValue(for: characteristic) - ErrorHandler.info("Reading WiFi AP SSID from \(peripheral.name ?? "a device")") } case BLEManager.Constants.UUIDs.wifiAPPassword: - ErrorHandler.info("Discovered 'WiFi AP Password' characteristic for \(peripheral.name ?? "a device")") + discoveredNames.append("WiFi Password") if characteristic.properties.contains(.read) { - // Read the current WiFi password peripheral.readValue(for: characteristic) - ErrorHandler.info("Reading WiFi AP Password from \(peripheral.name ?? "a device")") } case BLEManager.Constants.UUIDs.wifiAPPower: - ErrorHandler.info("Discovered 'WiFi AP Power' characteristic for \(peripheral.name ?? "a device")") + discoveredNames.append("WiFi Power") case BLEManager.Constants.UUIDs.wifiAPState: - ErrorHandler.info("Discovered 'WiFi AP State' characteristic for \(peripheral.name ?? "a device")") + discoveredNames.append("WiFi State") if characteristic.properties.contains(.read) { - // Read the current WiFi AP state peripheral.readValue(for: characteristic) - ErrorHandler.info("Reading WiFi AP State from \(peripheral.name ?? "a device")") } if characteristic.properties.contains(.indicate) { - peripheral.setNotifyValue(true, for: characteristic) // Enable indications - ErrorHandler.info("Subscribed to indications for 'WiFi AP State'") + peripheral.setNotifyValue(true, for: characteristic) } default: - ErrorHandler.info("Discovered unknown characteristic \(characteristic.uuid) for \(peripheral.name ?? "a device")") + ErrorHandler.debug("Unknown characteristic \(characteristic.uuid) for \(deviceName)") } } + if !discoveredNames.isEmpty { + ErrorHandler.debug("Configured \(discoveredNames.count) characteristics for \(deviceName): \(discoveredNames.joined(separator: ", "))") + } + bleManager.claimControl(for: peripheral.identifier) } } diff --git a/Facett/BLEConnectionManager.swift b/Facett/BLEConnectionManager.swift index 3762787..c96166b 100644 --- a/Facett/BLEConnectionManager.swift +++ b/Facett/BLEConnectionManager.swift @@ -80,7 +80,7 @@ class BLEConnectionManager: ObservableObject { private var connectionRetryTimers: [UUID: Timer] = [:] private var connectionAttemptTimers: [UUID: Timer] = [:] private var maxRetryAttempts = 3 - private var connectionTimeout: TimeInterval = 30.0 + private var connectionTimeout: TimeInterval = 15.0 // Callbacks var onConnectionSuccess: ((UUID) -> Void)? @@ -91,7 +91,7 @@ class BLEConnectionManager: ObservableObject { /// Start connection retry process for a device func startConnectionRetry(for uuid: UUID, maxAttempts: Int = 3) { - ErrorHandler.info("Starting connection retry for device \(uuid)") + ErrorHandler.debug("Starting connection retry for device \(uuid)") connectionRetryCount[uuid] = 0 maxRetryAttempts = maxAttempts @@ -104,7 +104,7 @@ class BLEConnectionManager: ObservableObject { private func attemptConnection(_ uuid: UUID) { guard let currentAttempt = connectionRetryCount[uuid] else { return } - ErrorHandler.info("Connection attempt \(currentAttempt + 1)/\(maxRetryAttempts) for device \(uuid)") + ErrorHandler.debug("Connection attempt \(currentAttempt + 1)/\(maxRetryAttempts) for device \(uuid)") // Start connection timeout timer connectionAttemptTimers[uuid] = Timer.scheduledTimer(withTimeInterval: connectionTimeout, repeats: false) { [weak self] _ in @@ -121,7 +121,7 @@ class BLEConnectionManager: ObservableObject { /// Handle successful connection func handleConnectionSuccess(_ uuid: UUID) { - ErrorHandler.info("Connection successful for device \(uuid)") + ErrorHandler.debug("Connection successful for device \(uuid)") // Cancel timers connectionAttemptTimers[uuid]?.invalidate() @@ -173,7 +173,7 @@ class BLEConnectionManager: ObservableObject { /// Cancel connection retry for a device func cancelConnectionRetry(for uuid: UUID) { - ErrorHandler.info("Cancelling connection retry for device \(uuid)") + ErrorHandler.debug("Cancelling connection retry for device \(uuid)") connectionRetryTimers[uuid]?.invalidate() connectionAttemptTimers[uuid]?.invalidate() diff --git a/Facett/BLEManager.swift b/Facett/BLEManager.swift index 3e069e3..f633992 100644 --- a/Facett/BLEManager.swift +++ b/Facett/BLEManager.swift @@ -313,44 +313,35 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph ErrorHandler.debug("First command added to queue, queue now has 1 command") } - // Start queue processing timer if not already running if commandQueueTimers[uuid] == nil { - ErrorHandler.debug("Starting timer for new queue") startCommandQueueTimer(for: uuid) - } else { - ErrorHandler.debug("Timer already running for this camera") } } /// Start the command queue processing timer for a specific camera private func startCommandQueueTimer(for uuid: UUID) { - ErrorHandler.debug("Starting command queue timer for \(CameraIdentityManager.shared.getDisplayName(for: uuid))") + // Invalidate any existing timer before creating a new one + commandQueueTimers[uuid]?.invalidate() - // Ensure timer is created on main thread and added to main run loop - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } + if Thread.isMainThread { let timer = Timer.scheduledTimer(withTimeInterval: self.commandQueueInterval, repeats: true) { [weak self] _ in self?.processCommandQueue(for: uuid) } self.commandQueueTimers[uuid] = timer - ErrorHandler.debug("Timer created and scheduled for \(CameraIdentityManager.shared.getDisplayName(for: uuid))") + } else { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.commandQueueTimers[uuid]?.invalidate() + let timer = Timer.scheduledTimer(withTimeInterval: self.commandQueueInterval, repeats: true) { [weak self] _ in + self?.processCommandQueue(for: uuid) + } + self.commandQueueTimers[uuid] = timer + } } } - /// Process the command queue for a specific camera private func processCommandQueue(for uuid: UUID) { - ErrorHandler.debug("Processing command queue for \(CameraIdentityManager.shared.getDisplayName(for: uuid))") - - guard let queue = commandQueues[uuid] else { - log("No command queue found for \(CameraIdentityManager.shared.getDisplayName(for: uuid))") - commandQueueTimers[uuid]?.invalidate() - commandQueueTimers[uuid] = nil - return - } - - guard !queue.isEmpty else { - ErrorHandler.debug("Queue is empty for \(CameraIdentityManager.shared.getDisplayName(for: uuid)), stopping timer") - // Queue is empty, stop the timer + guard let queue = commandQueues[uuid], !queue.isEmpty else { commandQueueTimers[uuid]?.invalidate() commandQueueTimers[uuid] = nil return @@ -469,6 +460,7 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph private var centralManager: CBCentralManager! private var deviceQueryTimer: Timer? private var deviceScanTimer: Timer? + private var keepAliveTimer: Timer? private var timeoutCheckTimer: Timer? private var settingsQueryCounter = 0 // Counter to reduce settings query frequency @@ -662,6 +654,7 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph stopDeviceQueryTimer() stopDeviceScanTimer() stopStragglerRetryTimer() + stopKeepAliveTimer() connectionRetryTimers.values.forEach { $0.invalidate() } connectionRetryTimers.removeAll() connectionAttemptTimers.values.forEach { $0.invalidate() } @@ -1913,16 +1906,13 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph guard let gopro = connectedGoPros[uuid] else { return } if sleep { - // For sleep, first release control, then send sleep command releaseControl(for: uuid) - // Wait a moment for control release, then send sleep command DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in guard let self = self else { return } self.sendSleepCommand(to: gopro) } } else { - // For normal disconnect, release control and disconnect immediately releaseControl(for: uuid) if let cbPeripheral = gopro.peripheral.cbPeripheral { centralManager.cancelPeripheralConnection(cbPeripheral) @@ -1932,14 +1922,16 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph } func powerDownGoPro(uuid: UUID) { - - guard let gopro = connectedGoPros[uuid] else { return } - sendPowerDownCommand(to: gopro) + sendPowerDownCommand(to: gopro) - removeDevice(from: \.connectedGoPros, uuid: uuid) // Use key path for connectedGoPros + removeDevice(from: \.connectedGoPros, uuid: uuid) log("\(gopro.peripheral.name ?? "GoPro") powered down.") + + if connectedGoPros.isEmpty { + stopKeepAliveTimer() + } } @@ -1952,13 +1944,10 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph let sleepCommand: [UInt8] = [1, 5] let uuid = gopro.peripheral.identifier - // Track this as a pending sleep command pendingSleepCommands.insert(uuid) - - // Mark device as sleeping deviceStateManager.setDeviceSleeping(uuid, isSleeping: true) + targetConnectedCameras.remove(uuid) - // Cancel any pending connection retry timers connectionRetryTimers[uuid]?.invalidate() connectionRetryTimers.removeValue(forKey: uuid) connectionAttemptTimers[uuid]?.invalidate() @@ -1986,8 +1975,8 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph let powerDownCommand: [UInt8] = [1, 4] let uuid = gopro.peripheral.identifier - // Track this as a pending power down command pendingPowerDownCommands.insert(uuid) + targetConnectedCameras.remove(uuid) sendCommand(powerDownCommand, to: gopro, actionDescription: "Powering down") @@ -2084,6 +2073,36 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph } } + // MARK: - Keep Alive + + func startKeepAliveTimer() { + stopKeepAliveTimer() + DispatchQueue.main.async { + self.keepAliveTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + self?.sendKeepAliveToAll() + } + } + } + + func stopKeepAliveTimer() { + DispatchQueue.main.async { + self.keepAliveTimer?.invalidate() + self.keepAliveTimer = nil + } + } + + private func sendKeepAliveToAll() { + let command = Data(GoProCommands.KeepAlive.ping) + for (_, gopro) in connectedGoPros { + guard let characteristic = findCharacteristic(for: gopro.peripheral, uuid: Constants.UUIDs.settings) else { + continue + } + bleCommandQueue.async { + gopro.peripheral.writeValue(command, for: characteristic, type: .withResponse) + } + } + } + func pauseScanning() { DispatchQueue.main.async { self.stopDeviceScanTimer() @@ -2170,6 +2189,7 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph // MARK: - Sleep and Wake Operations func putCamerasToSleep() { + stopKeepAliveTimer() connectedGoPros.keys.forEach { disconnectFromGoPro(uuid: $0, sleep: true) } } @@ -2310,24 +2330,22 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph } if !stragglers.isEmpty { - log("Found \(stragglers.count) straggler cameras, attempting to reconnect...") + ErrorHandler.info("Reconnecting \(stragglers.count) dropped camera(s)...") for cameraId in stragglers { let currentRetryCount = stragglerRetryCount[cameraId] ?? 0 if currentRetryCount < maxStragglerRetries { stragglerRetryCount[cameraId] = currentRetryCount + 1 - log("Retrying straggler camera \(CameraIdentityManager.shared.getDisplayName(for: cameraId)) (attempt \(currentRetryCount + 1)/\(maxStragglerRetries))") + ErrorHandler.info("Reconnecting \(CameraIdentityManager.shared.getDisplayName(for: cameraId)) (attempt \(currentRetryCount + 1)/\(maxStragglerRetries))") connectToGoPro(uuid: cameraId) } else { - log("Giving up on straggler camera \(CameraIdentityManager.shared.getDisplayName(for: cameraId)) after \(maxStragglerRetries) attempts") - // Remove from target cameras if we've given up + ErrorHandler.warning("Giving up on \(CameraIdentityManager.shared.getDisplayName(for: cameraId)) after \(maxStragglerRetries) reconnect attempts") targetConnectedCameras.remove(cameraId) } } } else { - // All target cameras are connected or connecting, stop the timer - log("All target cameras connected, stopping straggler retry timer") + ErrorHandler.debug("All target cameras connected") stopStragglerRetryTimer() logConnectionSummary() } @@ -2348,6 +2366,17 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph stragglerRetryCount.removeAll() } + /// Schedule auto-reconnect for a camera that dropped unexpectedly + func scheduleReconnectIfNeeded(for uuid: UUID) { + guard targetConnectedCameras.contains(uuid) else { return } + + let cameraName = CameraIdentityManager.shared.getDisplayName(for: uuid) + ErrorHandler.info("Camera \(cameraName) dropped - scheduling reconnect") + + stragglerRetryCount[uuid] = 0 + startStragglerRetryTimer() + } + /// Log a summary of connection status for debugging private func logConnectionSummary() { let totalTarget = targetConnectedCameras.count diff --git a/Facett/BLEParser.swift b/Facett/BLEParser.swift index 06aeaaf..5d1a17a 100644 --- a/Facett/BLEParser.swift +++ b/Facett/BLEParser.swift @@ -153,6 +153,8 @@ class BLEResponseMapper { return .batteryLevel(Int(entry.value)) // Internal Battery Bars (quantized level) case 3: return .externalBatteryPresent(entry.value == 1) // External Battery Present + case 4: return nil // System Hot (granular temp indicator, handled via case 6 overheating) + case 5: return nil // System Busy (duplicate of case 8) case 6: return .overheating(entry.value == 1) case 8: return .isBusy(entry.value == 1) case 10: return .encoding(entry.value == 1) diff --git a/Facett/BLERecordingManager.swift b/Facett/BLERecordingManager.swift index 276c610..b885b9b 100644 --- a/Facett/BLERecordingManager.swift +++ b/Facett/BLERecordingManager.swift @@ -114,36 +114,48 @@ class BLERecordingManager { } } + private let recordingStaggerDelay: TimeInterval = 0.05 + /// Start recording for cameras in a specific set func startRecordingForCamerasInSet(_ cameraIds: Set) { guard let bleManager = bleManager else { return } let cameraCount = cameraIds.count - // Provide haptic feedback and voice notification for batch recording start DispatchQueue.main.async { self.recordingStartedHaptic() VoiceNotificationManager.shared.notifyRecordingStarted(cameraCount: cameraCount) } - bleManager.connectedGoPros.forEach { uuid, gopro in - if cameraIds.contains(uuid) { - startRecording(for: gopro.peripheral.identifier) - } - } + let cameras = bleManager.connectedGoPros.filter { cameraIds.contains($0.key) } + sendStaggered(cameras: Array(cameras.values), action: { [weak self] uuid in + self?.startRecording(for: uuid) + }) } /// Stop recording for cameras in a specific set func stopRecordingForCamerasInSet(_ cameraIds: Set) { guard let bleManager = bleManager else { return } let cameraCount = cameraIds.count - // Provide haptic feedback and voice notification for batch recording stop DispatchQueue.main.async { self.recordingStoppedHaptic() VoiceNotificationManager.shared.notifyRecordingStopped(cameraCount: cameraCount) } - bleManager.connectedGoPros.forEach { uuid, gopro in - if cameraIds.contains(uuid) { - stopRecording(for: gopro.peripheral.identifier) + let cameras = bleManager.connectedGoPros.filter { cameraIds.contains($0.key) } + sendStaggered(cameras: Array(cameras.values), action: { [weak self] uuid in + self?.stopRecording(for: uuid) + }) + } + + private func sendStaggered(cameras: [GoPro], action: @escaping (UUID) -> Void) { + for (index, gopro) in cameras.enumerated() { + let delay = TimeInterval(index) * recordingStaggerDelay + let uuid = gopro.peripheral.identifier + if delay == 0 { + action(uuid) + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + action(uuid) + } } } } diff --git a/Facett/BLEResponseHandler.swift b/Facett/BLEResponseHandler.swift index 5022ba7..9d9c1d8 100644 --- a/Facett/BLEResponseHandler.swift +++ b/Facett/BLEResponseHandler.swift @@ -53,7 +53,12 @@ class BLEResponseHandler { case .isBusy(let isBusy): gopro.status.isBusy = isBusy case .encoding(let isEncoding): + let wasEncoding = gopro.status.isEncoding ?? false gopro.status.isEncoding = isEncoding + if isEncoding != wasEncoding { + let name = CameraIdentityManager.shared.getDisplayName(for: uuid, currentName: gopro.name) + ErrorHandler.info("Camera \(name) \(isEncoding ? "started" : "stopped") recording") + } case .videoEncodingDuration(let seconds): gopro.status.videoEncodingDuration = seconds case .sdCardRemaining(let remaining): diff --git a/Facett/CameraGroup.swift b/Facett/CameraGroup.swift index 83d38fd..4aa29f5 100644 --- a/Facett/CameraGroup.swift +++ b/Facett/CameraGroup.swift @@ -101,29 +101,22 @@ struct GroupStatus { let recordingCameras: Int let connectingCameras: Int let initializingCameras: Int + let settingsMismatchCameras: Int + let modeMismatchCameras: Int + /// Group status reflects the least-progressed camera in the group. + /// Progression: disconnected → connecting → initializing → modeMismatch → settingsMismatch → ready + /// Errors and recording override the progression since they require immediate attention. var overallStatus: CameraStatus { - if errorCameras > 0 { - return .error - } else if recordingCameras > 0 { - // If any cameras are recording, show recording status - return .recording - } else if connectingCameras > 0 { - // If any cameras are actively connecting, show connecting status - return .connecting - } else if initializingCameras > 0 { - // If any cameras are initializing, show initializing status - return .initializing - } else if readyCameras == totalCameras { - return .ready - } else if disconnectedCameras == totalCameras { - return .disconnected - } else if disconnectedCameras > 0 { - // If some cameras are disconnected, show settings mismatch - return .settingsMismatch - } else { - return .error // Fallback for unexpected states - } + if errorCameras > 0 { return .error } + if recordingCameras > 0 { return .recording } + if disconnectedCameras > 0 { return .disconnected } + if connectingCameras > 0 { return .connecting } + if initializingCameras > 0 { return .initializing } + if modeMismatchCameras > 0 { return .modeMismatch } + if settingsMismatchCameras > 0 { return .settingsMismatch } + if readyCameras == totalCameras { return .ready } + return .disconnected } var statusMessage: String { @@ -144,6 +137,12 @@ struct GroupStatus { if initializingCameras > 0 { parts.append("\(initializingCameras) initializing") } + if settingsMismatchCameras > 0 { + parts.append("\(settingsMismatchCameras) mismatched") + } + if modeMismatchCameras > 0 { + parts.append("\(modeMismatchCameras) wrong mode") + } if errorCameras > 0 { parts.append("\(errorCameras) errors") } @@ -273,18 +272,24 @@ class CameraGroupManager: ObservableObject { var errorCameras = 0 var recordingCameras = 0 var initializingCameras = 0 + var settingsMismatchCameras = 0 + var modeMismatchCameras = 0 for (_, camera) in cameras { let status = getCameraStatus(camera, bleManager: bleManager) switch status { case .ready: readyCameras += 1 - case .error, .overheating, .noSDCard: + case .error, .overheating, .noSDCard, .lowBattery: errorCameras += 1 case .recording: recordingCameras += 1 case .initializing: initializingCameras += 1 + case .settingsMismatch: + settingsMismatchCameras += 1 + case .modeMismatch: + modeMismatchCameras += 1 default: break } @@ -297,7 +302,9 @@ class CameraGroupManager: ObservableObject { disconnectedCameras: disconnectedCameras, recordingCameras: recordingCameras, connectingCameras: connectingCameras, - initializingCameras: initializingCameras + initializingCameras: initializingCameras, + settingsMismatchCameras: settingsMismatchCameras, + modeMismatchCameras: modeMismatchCameras ) } diff --git a/Facett/ContentView.swift b/Facett/ContentView.swift index 5cfd940..7296d8d 100644 --- a/Facett/ContentView.swift +++ b/Facett/ContentView.swift @@ -25,14 +25,6 @@ struct ContentView: View { let mismatchedCameras = checkForModeMismatches() if !mismatchedCameras.isEmpty && !showModeMismatchModal { - ErrorHandler.info( - "Mode Mismatch Detected - Showing Modal", - context: [ - "mismatched_cameras": mismatchedCameras.map { camera in - CameraIdentityManager.shared.getDisplayName(for: camera.peripheral.identifier, currentName: camera.name) - } as Any - ] - ) modeMismatchCameras = mismatchedCameras showModeMismatchModal = true } @@ -42,39 +34,28 @@ struct ContentView: View { let group = cameraGroupManager.effectiveGroup(bleManager: bleManager) let camerasToCheck = group.cameraIds.compactMap { bleManager.connectedGoPros[$0] } - // Debug logging - ErrorHandler.info( - "Mode Mismatch Check Debug", - context: [ - "total_cameras_checked": camerasToCheck.count, - "camera_details": camerasToCheck.map { camera in - let name = CameraIdentityManager.shared.getDisplayName(for: camera.peripheral.identifier, currentName: camera.name) - return "\(name): mode=\(camera.settings.mode), encoding=\(camera.status.isEncoding ?? false)" - } - ] - ) - let mismatchedCameras = camerasToCheck.filter { camera in - // Skip mode mismatch checks for recording cameras to avoid false positives if camera.status.isEncoding == true { return false } - - // Check if camera is in video mode (required for recording) - // Mode 12 = Video, Mode 17 = Photo, Mode 19 = Multishot (Burst Photo) return camera.settings.mode != 12 } - ErrorHandler.info( - "Mode Mismatch Check Results", - context: [ - "mismatched_count": mismatchedCameras.count, - "mismatched_cameras": mismatchedCameras.map { camera in - let name = CameraIdentityManager.shared.getDisplayName(for: camera.peripheral.identifier, currentName: camera.name) - return "\(name): mode \(camera.settings.mode)" - } - ] - ) + if mismatchedCameras.isEmpty { + ErrorHandler.debug("Mode mismatch check: all \(camerasToCheck.count) cameras in video mode") + } else { + ErrorHandler.info( + "Mode Mismatch Detected", + context: [ + "mismatched_count": mismatchedCameras.count, + "total_checked": camerasToCheck.count, + "mismatched_cameras": mismatchedCameras.map { camera in + let name = CameraIdentityManager.shared.getDisplayName(for: camera.peripheral.identifier, currentName: camera.name) + return "\(name): mode \(camera.settings.mode)" + } + ] + ) + } return mismatchedCameras } @@ -82,34 +63,11 @@ struct ContentView: View { private func handleRecordingAction() { let mismatchedCameras = checkForModeMismatches() - // Debug logging - ErrorHandler.info( - "Recording Action Debug", - context: [ - "mismatched_cameras_count": mismatchedCameras.count, - "camera_modes": mismatchedCameras.map { camera in - let name = CameraIdentityManager.shared.getDisplayName(for: camera.peripheral.identifier, currentName: camera.name) - return "\(name): mode \(camera.settings.mode)" - } - ] - ) - if !mismatchedCameras.isEmpty { - ErrorHandler.info( - "Showing Mode Mismatch Modal", - context: [ - "mismatched_cameras": mismatchedCameras.map { camera in - CameraIdentityManager.shared.getDisplayName(for: camera.peripheral.identifier, currentName: camera.name) - } - ] - ) modeMismatchCameras = mismatchedCameras showModeMismatchModal = true } else { - ErrorHandler.info( - "No Mode Mismatches - Starting Recording", - context: ["action": "start_recording"] - ) + ErrorHandler.info("Starting recording for group") let group = cameraGroupManager.effectiveGroup(bleManager: bleManager) bleManager.startRecordingForCamerasInSet(group.cameraSerials) } @@ -193,11 +151,7 @@ struct ContentView: View { let bleManager = bleManager, let cameraGroupManager = cameraGroupManager else { return } - ErrorHandler.debug("Camera '\(cameraId)' received initial status - triggering immediate sync check") - ErrorHandler.info( - "Camera Status Updated - Checking Mode Mismatches", - context: ["camera_id": cameraId.uuidString] - ) + ErrorHandler.debug("Camera '\(cameraId)' received initial status - triggering sync check") configManager.checkAndTriggerAutoSync(bleManager: bleManager, cameraGroupManager: cameraGroupManager) // Check for mode mismatches and show modal if needed diff --git a/FacettTests/StateMachineTests.swift b/FacettTests/StateMachineTests.swift index df10d86..00d7de7 100644 --- a/FacettTests/StateMachineTests.swift +++ b/FacettTests/StateMachineTests.swift @@ -275,6 +275,8 @@ class StateMachineTests: XCTestCase { let recordingCameras = statuses.filter { $0 == .recording }.count let connectingCameras = statuses.filter { $0 == .connecting }.count let initializingCameras = statuses.filter { $0 == .initializing }.count + let settingsMismatchCameras = statuses.filter { $0 == .settingsMismatch }.count + let modeMismatchCameras = statuses.filter { $0 == .modeMismatch }.count // Handle empty group case if totalCameras == 0 { @@ -285,7 +287,9 @@ class StateMachineTests: XCTestCase { disconnectedCameras: 0, recordingCameras: 0, connectingCameras: 0, - initializingCameras: 0 + initializingCameras: 0, + settingsMismatchCameras: 0, + modeMismatchCameras: 0 ) } @@ -296,7 +300,9 @@ class StateMachineTests: XCTestCase { disconnectedCameras: disconnectedCameras, recordingCameras: recordingCameras, connectingCameras: connectingCameras, - initializingCameras: initializingCameras + initializingCameras: initializingCameras, + settingsMismatchCameras: settingsMismatchCameras, + modeMismatchCameras: modeMismatchCameras ) }