mirror of
https://github.com/usatiuk/cardboy.git
synced 2025-10-28 23:27:49 +01:00
background sync
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 = "<group>";
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
shouldKeepScanning = true
|
||||
stopScanning()
|
||||
targetPeripheral = nil
|
||||
timeCharacteristic = nil
|
||||
central.scanForPeripherals(withServices: [serviceUUID], options: [
|
||||
CBCentralManagerScanOptionAllowDuplicatesKey: false
|
||||
])
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user