From eeedc629d7cb0d12f7c78fa0128c48ef4994ce10 Mon Sep 17 00:00:00 2001 From: Stepan Usatiuk Date: Sun, 19 Oct 2025 20:26:40 +0200 Subject: [PATCH] background sync --- Firmware/cardboy-companion/README.md | 4 +- .../project.pbxproj | 17 +++ .../cardboy-companion/Info.plist | 10 ++ .../cardboy-companion/TimeSyncManager.swift | 115 ++++++++++++++---- .../backend-esp/src/time_sync_service.cpp | 4 +- 5 files changed, 119 insertions(+), 31 deletions(-) create mode 100644 Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Info.plist diff --git a/Firmware/cardboy-companion/README.md b/Firmware/cardboy-companion/README.md index 1d12c60..4a84078 100644 --- a/Firmware/cardboy-companion/README.md +++ b/Firmware/cardboy-companion/README.md @@ -22,8 +22,8 @@ This SwiftUI app connects to the Cardboy device over Bluetooth Low Energy and up ## Usage 1. Open `cardboy-companion/cardboy-companion.xcodeproj` in Xcode. -2. Enable the `CoreBluetooth` capability for the `cardboy-companion` target (Signing & Capabilities tab). +2. Ensure the `CoreBluetooth` capability is enabled for the `cardboy-companion` target and keep the *Uses Bluetooth LE accessories* background mode on (preconfigured in this project). 3. Build & run on a real device (BLE is not available in the simulator). -4. Allow Bluetooth permissions when prompted, then tap **Sync Now**. +4. Allow Bluetooth permissions when prompted. The app keeps scanning in the background, so the Cardboy can request a sync even while the companion is not foregrounded. Tap **Sync Now** any time you want to trigger a manual refresh. Optionally bundle this code into your existing app—`TimeSyncManager` is self‑contained and can be reused. diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion.xcodeproj/project.pbxproj b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion.xcodeproj/project.pbxproj index bd64972..adbaa67 100644 --- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion.xcodeproj/project.pbxproj +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion.xcodeproj/project.pbxproj @@ -10,9 +10,22 @@ ECAB9A832EA550D9004BA9DE /* cardboy-companion.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "cardboy-companion.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + ECAB9ABA2EA562CD004BA9DE /* Exceptions for "cardboy-companion" folder in "cardboy-companion" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = ECAB9A822EA550D9004BA9DE /* cardboy-companion */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ ECAB9A852EA550D9004BA9DE /* cardboy-companion */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ECAB9ABA2EA562CD004BA9DE /* Exceptions for "cardboy-companion" folder in "cardboy-companion" target */, + ); path = "cardboy-companion"; sourceTree = ""; }; @@ -254,10 +267,12 @@ DEVELOPMENT_TEAM = WX524QS7SH; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "cardboy-companion/Info.plist"; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld."; INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UIBackgroundModes = "bluetooth-central"; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -288,10 +303,12 @@ DEVELOPMENT_TEAM = WX524QS7SH; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "cardboy-companion/Info.plist"; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld."; INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UIBackgroundModes = "bluetooth-central"; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Info.plist b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Info.plist new file mode 100644 index 0000000..e999718 --- /dev/null +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Info.plist @@ -0,0 +1,10 @@ + + + + + UIBackgroundModes + + bluetooth-central + + + diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift index 32a175c..200f801 100644 --- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift @@ -19,28 +19,37 @@ final class TimeSyncManager: NSObject, ObservableObject { private let serviceUUID = CBUUID(string: "00000001-CA7B-4EFD-B5A6-10C3F4D3F230") private let characteristicUUID = CBUUID(string: "00000002-CA7B-4EFD-B5A6-10C3F4D3F231") - private var central: CBCentralManager! + private lazy var central: CBCentralManager = CBCentralManager( + delegate: self, + queue: nil, + options: [ + CBCentralManagerOptionShowPowerAlertKey: true, + CBCentralManagerOptionRestoreIdentifierKey: "com.usatiuk.cardboy-companion.central" + ] + ) + private var targetPeripheral: CBPeripheral? private var timeCharacteristic: CBCharacteristic? - private var retryTimer: Timer? + private var retryWorkItem: DispatchWorkItem? + private var shouldKeepScanning = true + private var isScanning = false override init() { super.init() - central = CBCentralManager(delegate: self, queue: nil) + } + + deinit { + retryWorkItem?.cancel() } func forceRescan() { statusMessage = "Restarting scan…" - connectionState = .scanning - if central.state == .poweredOn { - central.stopScan() - targetPeripheral = nil - timeCharacteristic = nil - central.scanForPeripherals(withServices: [serviceUUID], options: [ - CBCentralManagerScanOptionAllowDuplicatesKey: false - ]) - } + shouldKeepScanning = true + stopScanning() + targetPeripheral = nil + timeCharacteristic = nil + startScanning() } func sendCurrentTime() { @@ -70,9 +79,55 @@ final class TimeSyncManager: NSObject, ObservableObject { statusMessage = "Sending current time…" peripheral.writeValue(payload, for: characteristic, type: .withResponse) } + + private func startScanning() { + guard shouldKeepScanning, central.state == .poweredOn else { return } + if isScanning { return } + + central.scanForPeripherals(withServices: [serviceUUID], options: [ + CBCentralManagerScanOptionAllowDuplicatesKey: false + ]) + + isScanning = true + connectionState = .scanning + statusMessage = "Scanning for Cardboy…" + } + + private func stopScanning() { + guard isScanning else { return } + central.stopScan() + isScanning = false + } + + private func scheduleRetry(after delay: TimeInterval = 2.5) { + shouldKeepScanning = true + retryWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.startScanning() + } + retryWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } } extension TimeSyncManager: CBCentralManagerDelegate { + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { + shouldKeepScanning = true + + if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral], + let restored = peripherals.first { + statusMessage = "Restoring connection…" + connectionState = .connecting + targetPeripheral = restored + restored.delegate = self + if central.state == .poweredOn { + central.connect(restored, options: nil) + } + } else { + startScanning() + } + } + func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .unknown, .resetting: @@ -87,12 +142,9 @@ extension TimeSyncManager: CBCentralManagerDelegate { case .poweredOff: connectionState = .idle statusMessage = "Turn on Bluetooth to continue." + stopScanning() case .poweredOn: - connectionState = .scanning - statusMessage = "Scanning for Cardboy…" - central.scanForPeripherals(withServices: [serviceUUID], options: [ - CBCentralManagerScanOptionAllowDuplicatesKey: false - ]) + startScanning() @unknown default: connectionState = .failed statusMessage = "Unknown Bluetooth state." @@ -103,9 +155,10 @@ extension TimeSyncManager: CBCentralManagerDelegate { rssi RSSI: NSNumber) { statusMessage = "Found \(peripheral.name ?? "device"), connecting…" connectionState = .connecting + shouldKeepScanning = false + stopScanning() targetPeripheral = peripheral peripheral.delegate = self - central.stopScan() central.connect(peripheral, options: nil) } @@ -118,21 +171,19 @@ extension TimeSyncManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { statusMessage = "Failed to connect. \(error?.localizedDescription ?? "")" connectionState = .failed + targetPeripheral = nil + timeCharacteristic = nil scheduleRetry() } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - statusMessage = "Disconnected. \(error?.localizedDescription ?? "")" + let reason = error?.localizedDescription ?? "Disconnected." + statusMessage = reason connectionState = .idle + targetPeripheral = nil + timeCharacteristic = nil scheduleRetry() } - - private func scheduleRetry() { - retryTimer?.invalidate() - retryTimer = Timer.scheduledTimer(withTimeInterval: 2.5, repeats: false) { [weak self] _ in - self?.forceRescan() - } - } } extension TimeSyncManager: CBPeripheralDelegate { @@ -182,11 +233,21 @@ extension TimeSyncManager: CBPeripheralDelegate { if let error { statusMessage = "Write failed: \(error.localizedDescription)" connectionState = .failed + scheduleRetry() return } lastSyncDate = Date() - statusMessage = "Time synced at \(DateFormatter.localizedString(from: lastSyncDate!, dateStyle: .none, timeStyle: .medium))." + if let date = lastSyncDate { + let formatted = DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .medium) + statusMessage = "Time synced at \(formatted)." + } else { + statusMessage = "Time synced." + } connectionState = .ready + + shouldKeepScanning = true + central.cancelPeripheralConnection(peripheral) } } + diff --git a/Firmware/components/backend-esp/src/time_sync_service.cpp b/Firmware/components/backend-esp/src/time_sync_service.cpp index b2cad8d..296fd60 100644 --- a/Firmware/components/backend-esp/src/time_sync_service.cpp +++ b/Firmware/components/backend-esp/src/time_sync_service.cpp @@ -163,9 +163,9 @@ void startAdvertising() { std::memset(&advParams, 0, sizeof(advParams)); advParams.conn_mode = BLE_GAP_CONN_MODE_UND; advParams.disc_mode = BLE_GAP_DISC_MODE_GEN; - const uint16_t advIntervalMin = BLE_GAP_ADV_ITVL_MS(3000); + const uint16_t advIntervalMin = BLE_GAP_ADV_ITVL_MS(1000); advParams.itvl_min = advIntervalMin; - advParams.itvl_max = BLE_GAP_ADV_ITVL_MS(4200); + advParams.itvl_max = BLE_GAP_ADV_ITVL_MS(1200); rc = ble_gap_adv_start(g_ownAddrType, nullptr, BLE_HS_FOREVER, &advParams, gapEventHandler, nullptr); if (rc != 0) {