From f4699116f1a23a9fda35d0d0ab995ee99b582222 Mon Sep 17 00:00:00 2001 From: Kevin Blackburn-Matzen Date: Sun, 22 Feb 2026 10:48:07 -0800 Subject: [PATCH 1/5] Fix false settings mismatch status and improve recording flow GroupStatus.overallStatus incorrectly reported "Settings Mismatch" whenever cameras were disconnected, even if all connected cameras matched. Now it properly tracks per-camera settings/mode mismatch counts and only shows "Ready" when every camera in the group is accounted for. Also staggers recording commands (50ms apart) to reduce BLE congestion, logs encoding state transitions per camera, and downgrades mode mismatch check logging to DEBUG when no mismatches exist. Co-authored-by: Cursor --- Facett/BLERecordingManager.swift | 32 +++++++++---- Facett/BLEResponseHandler.swift | 5 ++ Facett/CameraGroup.swift | 38 ++++++++++----- Facett/ContentView.swift | 80 +++++++------------------------- 4 files changed, 70 insertions(+), 85 deletions(-) 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..58648b8 100644 --- a/Facett/CameraGroup.swift +++ b/Facett/CameraGroup.swift @@ -101,28 +101,28 @@ struct GroupStatus { let recordingCameras: Int let connectingCameras: Int let initializingCameras: Int + let settingsMismatchCameras: Int + let modeMismatchCameras: Int 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 + } else if settingsMismatchCameras > 0 { return .settingsMismatch + } else if modeMismatchCameras > 0 { + return .modeMismatch + } else if readyCameras + recordingCameras == totalCameras { + return .ready + } else if disconnectedCameras > 0 && readyCameras > 0 { + return .connecting } else { - return .error // Fallback for unexpected states + return .disconnected } } @@ -144,6 +144,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 +279,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 +309,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 From 24e5aff090e45560a347a4df13e2a76217fef3ab Mon Sep 17 00:00:00 2001 From: Kevin Blackburn-Matzen Date: Sun, 22 Feb 2026 11:33:46 -0800 Subject: [PATCH 2/5] Fix StateMachineTests to include new GroupStatus parameters Add settingsMismatchCameras and modeMismatchCameras to GroupStatus init calls in test helper to match the updated struct. Co-authored-by: Cursor --- FacettTests/StateMachineTests.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 ) } From 5b0adc737d57fa627f3fe42c172fc6f071081656 Mon Sep 17 00:00:00 2001 From: Kevin Blackburn-Matzen Date: Sun, 22 Feb 2026 11:04:27 -0800 Subject: [PATCH 3/5] Reduce log noise, fix queue timer bug, and add auto-reconnect Fix command queue timer race condition where multiple timers were created for the same camera due to async timer creation, causing hundreds of spurious "queue is empty" messages. Timer creation is now synchronous on the main thread with proper invalidation of existing timers. Add auto-reconnect for cameras that drop unexpectedly while the group is in a connected state. Uses the existing straggler retry mechanism (up to 5 attempts, 15s interval). Intentional disconnects (sleep, power down) remove cameras from the target set to prevent unwanted reconnection. Also: handle GoPro status types 4/5 to eliminate "Unknown status type" spam, downgrade per-characteristic discovery and connection retry logs from INFO to DEBUG, and reduce connection timeout from 30s to 15s. Co-authored-by: Cursor --- Facett/BLEConnectionHandler.swift | 66 +++++++++++++++---------------- Facett/BLEConnectionManager.swift | 10 ++--- Facett/BLEManager.swift | 63 ++++++++++++++--------------- Facett/BLEParser.swift | 2 + 4 files changed, 70 insertions(+), 71 deletions(-) diff --git a/Facett/BLEConnectionHandler.swift b/Facett/BLEConnectionHandler.swift index 1d25c8a..0683c35 100644 --- a/Facett/BLEConnectionHandler.swift +++ b/Facett/BLEConnectionHandler.swift @@ -55,31 +55,35 @@ 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 wasConnected && !isSleeping { + bleManager.scheduleReconnectIfNeeded(for: uuid) + } } } @@ -104,7 +108,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 +123,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 +156,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..b7a0ce4 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 @@ -1952,13 +1943,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 +1974,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") @@ -2310,24 +2298,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 +2334,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) From 44b10c9441e192f6b959c75f914f4ca0f25d556a Mon Sep 17 00:00:00 2001 From: Kevin Blackburn-Matzen Date: Sun, 22 Feb 2026 11:10:52 -0800 Subject: [PATCH 4/5] Clarify group status semantics with minimum-progress model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group status now reflects the least-progressed camera in the group, making the state progression predictable: disconnected → connecting → initializing → ready. Errors and recording override since they require immediate attention. The status message continues to show the detailed per-state breakdown. Co-authored-by: Cursor --- Facett/CameraGroup.swift | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/Facett/CameraGroup.swift b/Facett/CameraGroup.swift index 58648b8..4aa29f5 100644 --- a/Facett/CameraGroup.swift +++ b/Facett/CameraGroup.swift @@ -104,26 +104,19 @@ struct GroupStatus { 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 { - return .recording - } else if connectingCameras > 0 { - return .connecting - } else if initializingCameras > 0 { - return .initializing - } else if settingsMismatchCameras > 0 { - return .settingsMismatch - } else if modeMismatchCameras > 0 { - return .modeMismatch - } else if readyCameras + recordingCameras == totalCameras { - return .ready - } else if disconnectedCameras > 0 && readyCameras > 0 { - return .connecting - } else { - return .disconnected - } + 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 { From bb89732e0a90a9f8a51d1bb17cc9596a7e705e30 Mon Sep 17 00:00:00 2001 From: Kevin Blackburn-Matzen Date: Sun, 22 Feb 2026 11:26:48 -0800 Subject: [PATCH 5/5] Add periodic keep-alive to prevent cameras from silently sleeping GoPro cameras auto-sleep when both the Auto Power Down and Keep Alive timers expire. Without periodic keep-alive messages, connected cameras could silently enter sleep while the app still considered them active, leading to an invalid state that also affected USB connectivity. Sends Keep Alive (0x5B) to all connected cameras every 3 seconds via the Settings characteristic, per Open GoPro API best practices. The timer starts when the first camera connects and stops on last disconnect or before explicit sleep/power-down commands. Co-authored-by: Cursor --- Facett/BLEConnectionHandler.swift | 10 +++++++ Facett/BLEManager.swift | 46 ++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/Facett/BLEConnectionHandler.swift b/Facett/BLEConnectionHandler.swift index 0683c35..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) } @@ -81,6 +87,10 @@ class BLEConnectionHandler { } } + if bleManager.connectedGoPros.isEmpty { + bleManager.stopKeepAliveTimer() + } + if wasConnected && !isSleeping { bleManager.scheduleReconnectIfNeeded(for: uuid) } diff --git a/Facett/BLEManager.swift b/Facett/BLEManager.swift index b7a0ce4..f633992 100644 --- a/Facett/BLEManager.swift +++ b/Facett/BLEManager.swift @@ -460,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 @@ -653,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() } @@ -1904,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) @@ -1923,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() + } } @@ -2072,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() @@ -2158,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) } }