background sync

This commit is contained in:
2025-10-19 20:26:40 +02:00
parent 8bb48daf6c
commit eeedc629d7
5 changed files with 119 additions and 31 deletions

View File

@@ -22,8 +22,8 @@ This SwiftUI app connects to the Cardboy device over Bluetooth Low Energy and up
## Usage ## Usage
1. Open `cardboy-companion/cardboy-companion.xcodeproj` in Xcode. 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). 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 selfcontained and can be reused. Optionally bundle this code into your existing app—`TimeSyncManager` is selfcontained and can be reused.

View File

@@ -10,9 +10,22 @@
ECAB9A832EA550D9004BA9DE /* cardboy-companion.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "cardboy-companion.app"; sourceTree = BUILT_PRODUCTS_DIR; }; ECAB9A832EA550D9004BA9DE /* cardboy-companion.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "cardboy-companion.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* 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 */ /* Begin PBXFileSystemSynchronizedRootGroup section */
ECAB9A852EA550D9004BA9DE /* cardboy-companion */ = { ECAB9A852EA550D9004BA9DE /* cardboy-companion */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
ECAB9ABA2EA562CD004BA9DE /* Exceptions for "cardboy-companion" folder in "cardboy-companion" target */,
);
path = "cardboy-companion"; path = "cardboy-companion";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -254,10 +267,12 @@
DEVELOPMENT_TEAM = WX524QS7SH; DEVELOPMENT_TEAM = WX524QS7SH;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = 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_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_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UIBackgroundModes = "bluetooth-central";
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
@@ -288,10 +303,12 @@
DEVELOPMENT_TEAM = WX524QS7SH; DEVELOPMENT_TEAM = WX524QS7SH;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = 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_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_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UIBackgroundModes = "bluetooth-central";
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
</dict>
</plist>

View File

@@ -19,28 +19,37 @@ final class TimeSyncManager: NSObject, ObservableObject {
private let serviceUUID = CBUUID(string: "00000001-CA7B-4EFD-B5A6-10C3F4D3F230") private let serviceUUID = CBUUID(string: "00000001-CA7B-4EFD-B5A6-10C3F4D3F230")
private let characteristicUUID = CBUUID(string: "00000002-CA7B-4EFD-B5A6-10C3F4D3F231") 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 targetPeripheral: CBPeripheral?
private var timeCharacteristic: CBCharacteristic? private var timeCharacteristic: CBCharacteristic?
private var retryTimer: Timer? private var retryWorkItem: DispatchWorkItem?
private var shouldKeepScanning = true
private var isScanning = false
override init() { override init() {
super.init() super.init()
central = CBCentralManager(delegate: self, queue: nil) }
deinit {
retryWorkItem?.cancel()
} }
func forceRescan() { func forceRescan() {
statusMessage = "Restarting scan…" statusMessage = "Restarting scan…"
connectionState = .scanning shouldKeepScanning = true
if central.state == .poweredOn { stopScanning()
central.stopScan() targetPeripheral = nil
targetPeripheral = nil timeCharacteristic = nil
timeCharacteristic = nil startScanning()
central.scanForPeripherals(withServices: [serviceUUID], options: [
CBCentralManagerScanOptionAllowDuplicatesKey: false
])
}
} }
func sendCurrentTime() { func sendCurrentTime() {
@@ -70,9 +79,55 @@ final class TimeSyncManager: NSObject, ObservableObject {
statusMessage = "Sending current time…" statusMessage = "Sending current time…"
peripheral.writeValue(payload, for: characteristic, type: .withResponse) 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 { 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) { func centralManagerDidUpdateState(_ central: CBCentralManager) {
switch central.state { switch central.state {
case .unknown, .resetting: case .unknown, .resetting:
@@ -87,12 +142,9 @@ extension TimeSyncManager: CBCentralManagerDelegate {
case .poweredOff: case .poweredOff:
connectionState = .idle connectionState = .idle
statusMessage = "Turn on Bluetooth to continue." statusMessage = "Turn on Bluetooth to continue."
stopScanning()
case .poweredOn: case .poweredOn:
connectionState = .scanning startScanning()
statusMessage = "Scanning for Cardboy…"
central.scanForPeripherals(withServices: [serviceUUID], options: [
CBCentralManagerScanOptionAllowDuplicatesKey: false
])
@unknown default: @unknown default:
connectionState = .failed connectionState = .failed
statusMessage = "Unknown Bluetooth state." statusMessage = "Unknown Bluetooth state."
@@ -103,9 +155,10 @@ extension TimeSyncManager: CBCentralManagerDelegate {
rssi RSSI: NSNumber) { rssi RSSI: NSNumber) {
statusMessage = "Found \(peripheral.name ?? "device"), connecting…" statusMessage = "Found \(peripheral.name ?? "device"), connecting…"
connectionState = .connecting connectionState = .connecting
shouldKeepScanning = false
stopScanning()
targetPeripheral = peripheral targetPeripheral = peripheral
peripheral.delegate = self peripheral.delegate = self
central.stopScan()
central.connect(peripheral, options: nil) central.connect(peripheral, options: nil)
} }
@@ -118,21 +171,19 @@ extension TimeSyncManager: CBCentralManagerDelegate {
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
statusMessage = "Failed to connect. \(error?.localizedDescription ?? "")" statusMessage = "Failed to connect. \(error?.localizedDescription ?? "")"
connectionState = .failed connectionState = .failed
targetPeripheral = nil
timeCharacteristic = nil
scheduleRetry() scheduleRetry()
} }
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
statusMessage = "Disconnected. \(error?.localizedDescription ?? "")" let reason = error?.localizedDescription ?? "Disconnected."
statusMessage = reason
connectionState = .idle connectionState = .idle
targetPeripheral = nil
timeCharacteristic = nil
scheduleRetry() scheduleRetry()
} }
private func scheduleRetry() {
retryTimer?.invalidate()
retryTimer = Timer.scheduledTimer(withTimeInterval: 2.5, repeats: false) { [weak self] _ in
self?.forceRescan()
}
}
} }
extension TimeSyncManager: CBPeripheralDelegate { extension TimeSyncManager: CBPeripheralDelegate {
@@ -182,11 +233,21 @@ extension TimeSyncManager: CBPeripheralDelegate {
if let error { if let error {
statusMessage = "Write failed: \(error.localizedDescription)" statusMessage = "Write failed: \(error.localizedDescription)"
connectionState = .failed connectionState = .failed
scheduleRetry()
return return
} }
lastSyncDate = Date() 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 connectionState = .ready
shouldKeepScanning = true
central.cancelPeripheralConnection(peripheral)
} }
} }

View File

@@ -163,9 +163,9 @@ void startAdvertising() {
std::memset(&advParams, 0, sizeof(advParams)); std::memset(&advParams, 0, sizeof(advParams));
advParams.conn_mode = BLE_GAP_CONN_MODE_UND; advParams.conn_mode = BLE_GAP_CONN_MODE_UND;
advParams.disc_mode = BLE_GAP_DISC_MODE_GEN; 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_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); rc = ble_gap_adv_start(g_ownAddrType, nullptr, BLE_HS_FOREVER, &advParams, gapEventHandler, nullptr);
if (rc != 0) { if (rc != 0) {