diff --git a/.travis.yml b/.travis.yml index 0b3983d9..6ee76693 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: objective-c -osx_image: xcode9 +osx_image: xcode9.2 # cache: cocoapods # podfile: Example/Podfile @@ -12,5 +12,5 @@ script: # Build cocoapods example project # - set -o pipefail && xcodebuild -workspace Example/xDripG5.xcworkspace -scheme xDripG5-Example -sdk iphonesimulator -destination name="iPhone SE" ONLY_ACTIVE_ARCH=NO | xcpretty # Build Travis project and run tests -- xcodebuild -project xDripG5.xcodeproj -scheme xDripG5 -sdk iphonesimulator11.0 build -destination name="iPhone SE" test +- xcodebuild -project xDripG5.xcodeproj -scheme xDripG5 -sdk iphonesimulator11.2 build -destination name="iPhone SE" test # - pod lib lint diff --git a/xDripG5.xcodeproj/project.pbxproj b/xDripG5.xcodeproj/project.pbxproj index bb0cc49f..7bd773e9 100644 --- a/xDripG5.xcodeproj/project.pbxproj +++ b/xDripG5.xcodeproj/project.pbxproj @@ -8,7 +8,11 @@ /* Begin PBXBuildFile section */ 430D64C51CB7846A00FCA750 /* NSData+CRC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430D64C41CB7846A00FCA750 /* NSData+CRC.swift */; }; + 431CE7631F8EEF6D00255374 /* CBPeripheral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431CE7621F8EEF6D00255374 /* CBPeripheral.swift */; }; + 431CE7671F91D0B300255374 /* PeripheralManager+G5.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431CE7661F91D0B300255374 /* PeripheralManager+G5.swift */; }; 4323115F1EFC870300B95E62 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4323115E1EFC870300B95E62 /* OSLog.swift */; }; + 43460F88200B30D10030C0E3 /* TransmitterIDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43460F87200B30D10030C0E3 /* TransmitterIDTests.swift */; }; + 435535D41FB2C1B000CE5A23 /* PeripheralManagerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435535D31FB2C1B000CE5A23 /* PeripheralManagerError.swift */; }; 43846AC61D8F896C00799272 /* CalibrationDataRxMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43846AC51D8F896C00799272 /* CalibrationDataRxMessage.swift */; }; 43846AC81D8F89BE00799272 /* CalibrationDataRxMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43846AC71D8F89BE00799272 /* CalibrationDataRxMessageTests.swift */; }; 43880F981D9E19FC009061A8 /* TransmitterVersionRxMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43880F971D9E19FC009061A8 /* TransmitterVersionRxMessage.swift */; }; @@ -45,6 +49,7 @@ 43E3978F1D566B170028E321 /* Glucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E3978E1D566B170028E321 /* Glucose.swift */; }; 43E397911D5692080028E321 /* GlucoseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E397901D5692080028E321 /* GlucoseTests.swift */; }; 43E397931D56950C0028E321 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E397921D56950C0028E321 /* HKUnit.swift */; }; + 43E4B1F21F8AF9790038823E /* PeripheralManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E4B1F11F8AF9790038823E /* PeripheralManager.swift */; }; 43EEA7111D14DC0800CBBDA0 /* AESCrypt.h in Headers */ = {isa = PBXBuildFile; fileRef = 43EEA70F1D14DC0800CBBDA0 /* AESCrypt.h */; settings = {ATTRIBUTES = (Public, ); }; }; 43EEA7121D14DC0800CBBDA0 /* AESCrypt.m in Sources */ = {isa = PBXBuildFile; fileRef = 43EEA7101D14DC0800CBBDA0 /* AESCrypt.m */; }; 43F82BCC1D035AA4006F5DD7 /* TransmitterTimeRxMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F82BCB1D035AA4006F5DD7 /* TransmitterTimeRxMessageTests.swift */; }; @@ -78,7 +83,11 @@ /* Begin PBXFileReference section */ 430D64C41CB7846A00FCA750 /* NSData+CRC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSData+CRC.swift"; sourceTree = ""; }; + 431CE7621F8EEF6D00255374 /* CBPeripheral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CBPeripheral.swift; sourceTree = ""; }; + 431CE7661F91D0B300255374 /* PeripheralManager+G5.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PeripheralManager+G5.swift"; sourceTree = ""; }; 4323115E1EFC870300B95E62 /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; + 43460F87200B30D10030C0E3 /* TransmitterIDTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransmitterIDTests.swift; sourceTree = ""; }; + 435535D31FB2C1B000CE5A23 /* PeripheralManagerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeripheralManagerError.swift; sourceTree = ""; }; 43846AC51D8F896C00799272 /* CalibrationDataRxMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationDataRxMessage.swift; sourceTree = ""; }; 43846AC71D8F89BE00799272 /* CalibrationDataRxMessageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationDataRxMessageTests.swift; sourceTree = ""; }; 43880F971D9E19FC009061A8 /* TransmitterVersionRxMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransmitterVersionRxMessage.swift; sourceTree = ""; }; @@ -118,6 +127,7 @@ 43E3978E1D566B170028E321 /* Glucose.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Glucose.swift; sourceTree = ""; }; 43E397901D5692080028E321 /* GlucoseTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseTests.swift; sourceTree = ""; }; 43E397921D56950C0028E321 /* HKUnit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; + 43E4B1F11F8AF9790038823E /* PeripheralManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeripheralManager.swift; sourceTree = ""; }; 43EEA70F1D14DC0800CBBDA0 /* AESCrypt.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AESCrypt.h; sourceTree = ""; }; 43EEA7101D14DC0800CBBDA0 /* AESCrypt.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AESCrypt.m; sourceTree = ""; }; 43F82BCB1D035AA4006F5DD7 /* TransmitterTimeRxMessageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransmitterTimeRxMessageTests.swift; sourceTree = ""; }; @@ -173,12 +183,16 @@ 43EEA7101D14DC0800CBBDA0 /* AESCrypt.m */, 43CABE0E1C350B2800005705 /* BluetoothManager.swift */, 43CABE0F1C350B2800005705 /* BluetoothServices.swift */, + 431CE7621F8EEF6D00255374 /* CBPeripheral.swift */, 43E3978A1D5668BD0028E321 /* CalibrationState.swift */, 43E3978E1D566B170028E321 /* Glucose.swift */, 43E397921D56950C0028E321 /* HKUnit.swift */, 43CABDF81C3506F100005705 /* Info.plist */, 430D64C41CB7846A00FCA750 /* NSData+CRC.swift */, 4323115E1EFC870300B95E62 /* OSLog.swift */, + 43E4B1F11F8AF9790038823E /* PeripheralManager.swift */, + 435535D31FB2C1B000CE5A23 /* PeripheralManagerError.swift */, + 431CE7661F91D0B300255374 /* PeripheralManager+G5.swift */, 43CABE111C350B2800005705 /* Transmitter.swift */, 43CE7CDB1CA77468003CC1B0 /* TransmitterStatus.swift */, 43CABDF61C3506F100005705 /* xDripG5.h */, @@ -197,6 +211,7 @@ 43DC87BF1C8B509B005BC30D /* NSData.swift */, 43F82BD31D037227006F5DD7 /* SessionStartRxMessageTests.swift */, 43F82BD11D037040006F5DD7 /* SessionStopRxMessageTests.swift */, + 43460F87200B30D10030C0E3 /* TransmitterIDTests.swift */, 43F82BCB1D035AA4006F5DD7 /* TransmitterTimeRxMessageTests.swift */, 43880F991D9E1BD7009061A8 /* TransmitterVersionRxMessageTests.swift */, ); @@ -347,17 +362,20 @@ files = ( 43CE7CCA1CA73B94003CC1B0 /* TransmitterVersionTxMessage.swift in Sources */, 43E3978B1D5668BD0028E321 /* CalibrationState.swift in Sources */, + 431CE7631F8EEF6D00255374 /* CBPeripheral.swift in Sources */, 43CABE2D1C350B3D00005705 /* TransmitterTimeRxMessage.swift in Sources */, 43CABE291C350B3D00005705 /* GlucoseRxMessage.swift in Sources */, 43CE7CDC1CA77468003CC1B0 /* TransmitterStatus.swift in Sources */, 43CABE271C350B3D00005705 /* BondRequestTxMessage.swift in Sources */, 4323115F1EFC870300B95E62 /* OSLog.swift in Sources */, + 43E4B1F21F8AF9790038823E /* PeripheralManager.swift in Sources */, 43CABE231C350B3D00005705 /* AuthChallengeRxMessage.swift in Sources */, 43CABE261C350B3D00005705 /* AuthStatusRxMessage.swift in Sources */, 43CE7CD41CA73CE8003CC1B0 /* GlucoseHistoryTxMessage.swift in Sources */, 43E397931D56950C0028E321 /* HKUnit.swift in Sources */, 43846AC61D8F896C00799272 /* CalibrationDataRxMessage.swift in Sources */, 43CE7CD01CA73C57003CC1B0 /* SessionStopTxMessage.swift in Sources */, + 431CE7671F91D0B300255374 /* PeripheralManager+G5.swift in Sources */, 43CABE2A1C350B3D00005705 /* GlucoseTxMessage.swift in Sources */, 43CE7CC81CA73AEB003CC1B0 /* FirmwareVersionTxMessage.swift in Sources */, 43CE7CCC1CA73BCC003CC1B0 /* BatteryStatusTxMessage.swift in Sources */, @@ -376,6 +394,7 @@ 43CE7CD21CA73CBC003CC1B0 /* CalibrateGlucoseTxMessage.swift in Sources */, 43F82BD01D035D68006F5DD7 /* SessionStopRxMessage.swift in Sources */, 43F82BCE1D035D5C006F5DD7 /* SessionStartRxMessage.swift in Sources */, + 435535D41FB2C1B000CE5A23 /* PeripheralManagerError.swift in Sources */, 43CABE251C350B3D00005705 /* AuthRequestTxMessage.swift in Sources */, 430D64C51CB7846A00FCA750 /* NSData+CRC.swift in Sources */, ); @@ -385,6 +404,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 43460F88200B30D10030C0E3 /* TransmitterIDTests.swift in Sources */, 43F82BCC1D035AA4006F5DD7 /* TransmitterTimeRxMessageTests.swift in Sources */, 43F82BD41D037227006F5DD7 /* SessionStartRxMessageTests.swift in Sources */, 43846AC81D8F89BE00799272 /* CalibrationDataRxMessageTests.swift in Sources */, @@ -461,6 +481,7 @@ TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; + WATCHOS_DEPLOYMENT_TARGET = 4.0; }; name = Debug; }; @@ -512,6 +533,7 @@ VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; + WATCHOS_DEPLOYMENT_TARGET = 4.0; }; name = Release; }; @@ -530,7 +552,9 @@ PRODUCT_BUNDLE_IDENTIFIER = com.loudnate.xDripG5; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos watchos watchsimulator"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + TARGETED_DEVICE_FAMILY = "1,2,4"; }; name = Debug; }; @@ -549,6 +573,8 @@ PRODUCT_BUNDLE_IDENTIFIER = com.loudnate.xDripG5; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos watchos watchsimulator"; + TARGETED_DEVICE_FAMILY = "1,2,4"; }; name = Release; }; diff --git a/xDripG5.xcodeproj/xcshareddata/xcschemes/xDripG5.xcscheme b/xDripG5.xcodeproj/xcshareddata/xcschemes/xDripG5.xcscheme index 2fc83025..693dfd0c 100644 --- a/xDripG5.xcodeproj/xcshareddata/xcschemes/xDripG5.xcscheme +++ b/xDripG5.xcodeproj/xcshareddata/xcschemes/xDripG5.xcscheme @@ -1,6 +1,6 @@ Bool { - return lhs.hashValue == rhs.hashValue -} - - -class BluetoothManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { +class BluetoothManager: NSObject { var stayConnected = true weak var delegate: BluetoothManagerDelegate? - private let log: OSLog + private let log = OSLog(category: "BluetoothManager") private var manager: CBCentralManager! = nil private var peripheral: CBPeripheral? { - didSet { - if let oldValue = oldValue { - oldValue.delegate = nil + get { + return peripheralManager?.peripheral + } + set { + guard let peripheral = newValue else { + peripheralManager = nil + return } - if let newValue = peripheral { - newValue.delegate = self + if let peripheralManager = peripheralManager { + peripheralManager.peripheral = peripheral + } else { + peripheralManager = PeripheralManager( + peripheral: peripheral, + configuration: .dexcomG5, + centralManager: manager + ) } } } + var peripheralManager: PeripheralManager? { + didSet { + oldValue?.delegate = nil + peripheralManager?.delegate = self + } + } + // MARK: - GCD Management - private var managerQueue = DispatchQueue(label: "com.loudnate.xDripG5.bluetoothManagerQueue", qos: .userInitiated) + private let managerQueue = DispatchQueue(label: "com.loudnate.xDripG5.bluetoothManagerQueue", qos: .utility) override init() { - log = OSLog(subsystem: Bundle(for: BluetoothManager.self).bundleIdentifier!, category: "BluetoothManager") - super.init() manager = CBCentralManager(delegate: self, queue: managerQueue, options: [CBCentralManagerOptionRestoreIdentifierKey: "com.loudnate.xDripG5"]) @@ -107,26 +90,36 @@ class BluetoothManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate // MARK: - Actions - func scanForPeripheral() { + func scanForPeripheral(after delay: TimeInterval = 0) { guard manager.state == .poweredOn else { return } + var connectOptions: [String: Any] = [:] + + #if swift(>=4.0.3) + if #available(iOS 11.2, watchOS 4.1, *), delay > 0 { + connectOptions[CBConnectPeripheralOptionStartDelayKey] = delay + } + #else + connectOptions[""] = 0 + #endif + if let peripheralID = self.peripheral?.identifier, let peripheral = manager.retrievePeripherals(withIdentifiers: [peripheralID]).first { - log.info("Re-connecting to known peripheral %{public}@", peripheral.identifier.uuidString) + log.info("Re-connecting to known peripheral %{public}@ in %zds", peripheral.identifier.uuidString, delay) self.peripheral = peripheral - self.manager.connect(peripheral, options: nil) + self.manager.connect(peripheral, options: connectOptions) } else if let peripheral = manager.retrieveConnectedPeripherals(withServices: [ - CBUUID(string: TransmitterServiceUUID.Advertisement.rawValue), - CBUUID(string: TransmitterServiceUUID.CGMService.rawValue) - ]).first, delegate == nil || delegate!.bluetoothManager(self, shouldConnectPeripheral: peripheral) { + TransmitterServiceUUID.advertisement.cbUUID, + TransmitterServiceUUID.cgmService.cbUUID + ]).first, delegate == nil || delegate!.bluetoothManager(self, shouldConnectPeripheral: peripheral) { log.info("Found system-connected peripheral: %{public}@", peripheral.identifier.uuidString) self.peripheral = peripheral - self.manager.connect(peripheral, options: nil) + self.manager.connect(peripheral, options: connectOptions) } else { log.info("Scanning for peripherals") manager.scanForPeripherals(withServices: [ - CBUUID(string: TransmitterServiceUUID.Advertisement.rawValue) + TransmitterServiceUUID.advertisement.cbUUID ], options: nil ) @@ -152,10 +145,14 @@ class BluetoothManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate */ fileprivate func scanAfterDelay() { - DispatchQueue.global(qos: DispatchQoS.QoSClass.utility).async { - Thread.sleep(forTimeInterval: 2) + if #available(iOS 11.2, watchOS 4.1, *) { + self.scanForPeripheral(after: TimeInterval(60 * 3)) + } else { + DispatchQueue.global(qos: DispatchQoS.QoSClass.utility).async { + Thread.sleep(forTimeInterval: 2) - self.scanForPeripheral() + self.scanForPeripheral() + } } } @@ -164,170 +161,18 @@ class BluetoothManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate disconnect() } - // MARK: - Operations - - /// The locking signal for the active operation - private let operationLock = NSCondition() - - /// The required conditions for the operation to complete - private var operationConditions: Set = [] - - /// Any error surfaced during the active operation - private var operationError: Error? - - func readValueForCharacteristicAndWait(_ UUID: CGMServiceCharacteristicUUID, timeout: TimeInterval = 2, expectingFirstByte firstByte: UInt8? = nil) throws -> Data { - guard manager.state == .poweredOn && operationConditions.isEmpty, let peripheral = peripheral else { - throw BluetoothManagerError.notReady - } - - guard let characteristic = getCharacteristicWithUUID(UUID) else { - throw BluetoothManagerError.unknownCharacteristic - } - - operationLock.lock() - operationConditions.insert(.valueUpdate(characteristic: characteristic, firstByte: firstByte)) - - peripheral.readValue(for: characteristic) - - let signaled = operationLock.wait(until: Date(timeIntervalSinceNow: timeout)) - - defer { - operationConditions = [] - operationError = nil - operationLock.unlock() - } - - if !signaled { - throw BluetoothManagerError.timeout - } else if let operationError = operationError { - throw BluetoothManagerError.cbPeripheralError(operationError) - } - - return characteristic.value ?? Data() - } - - func setNotifyEnabledAndWait(_ enabled: Bool, forCharacteristicUUID UUID: CGMServiceCharacteristicUUID, timeout: TimeInterval = 2) throws { - guard manager.state == .poweredOn && operationConditions.isEmpty, let peripheral = peripheral else { - throw BluetoothManagerError.notReady - } - - guard let characteristic = getCharacteristicWithUUID(UUID) else { - throw BluetoothManagerError.unknownCharacteristic - } - - operationLock.lock() - operationConditions.insert(.notificationStateUpdate(characteristic: characteristic, enabled: enabled)) - - peripheral.setNotifyValue(enabled, for: characteristic) - - let signaled = operationLock.wait(until: Date(timeIntervalSinceNow: timeout)) - - defer { - operationConditions = [] - operationError = nil - operationLock.unlock() - } - - if !signaled { - throw BluetoothManagerError.timeout - } else if let operationError = operationError { - throw BluetoothManagerError.cbPeripheralError(operationError) - } - } - - func waitForCharacteristicValueUpdate(_ UUID: CGMServiceCharacteristicUUID, timeout: TimeInterval = 5, expectingFirstByte firstByte: UInt8? = nil) throws -> Data { - guard manager.state == .poweredOn && operationConditions.isEmpty && peripheral != nil else { - throw BluetoothManagerError.notReady - } - - guard let characteristic = getCharacteristicWithUUID(UUID) , characteristic.isNotifying else { - throw BluetoothManagerError.unknownCharacteristic - } - - operationLock.lock() - operationConditions.insert(.valueUpdate(characteristic: characteristic, firstByte: firstByte)) - - let signaled = operationLock.wait(until: Date(timeIntervalSinceNow: timeout)) - - defer { - operationConditions = [] - operationError = nil - operationLock.unlock() - } - - if !signaled { - throw BluetoothManagerError.timeout - } else if let operationError = operationError { - throw BluetoothManagerError.cbPeripheralError(operationError) - } - - return characteristic.value ?? Data() - } - - func writeValueAndWait(_ value: Data, forCharacteristicUUID UUID: CGMServiceCharacteristicUUID, timeout: TimeInterval = 2, expectingFirstByte firstByte: UInt8? = nil) throws -> Data { - guard manager.state == .poweredOn && operationConditions.isEmpty, let peripheral = peripheral else { - throw BluetoothManagerError.notReady - } - - guard let characteristic = getCharacteristicWithUUID(UUID) else { - throw BluetoothManagerError.unknownCharacteristic - } - - operationLock.lock() - operationConditions.insert(.writeUpdate(characteristic: characteristic)) - - if characteristic.isNotifying { - operationConditions.insert(.valueUpdate(characteristic: characteristic, firstByte: firstByte)) - } - - peripheral.writeValue(value, for: characteristic, type: .withResponse) - - let signaled = operationLock.wait(until: Date(timeIntervalSinceNow: timeout)) - - defer { - operationConditions = [] - operationError = nil - operationLock.unlock() - } - - if !signaled { - throw BluetoothManagerError.timeout - } else if let operationError = operationError { - throw BluetoothManagerError.cbPeripheralError(operationError) - } - - return characteristic.value ?? Data() - } - // MARK: - Accessors var isScanning: Bool { return manager.isScanning } +} - private func getServiceWithUUID(_ UUID: TransmitterServiceUUID) -> CBService? { - guard let services = peripheral?.services else { - return nil - } - - return services.itemWithUUIDString(UUID.rawValue) - } - - private func getCharacteristicForServiceUUID(_ serviceUUID: TransmitterServiceUUID, withUUIDString UUIDString: String) -> CBCharacteristic? { - guard let characteristics = getServiceWithUUID(serviceUUID)?.characteristics else { - return nil - } - - return characteristics.itemWithUUIDString(UUIDString) - } - - private func getCharacteristicWithUUID(_ UUID: CGMServiceCharacteristicUUID) -> CBCharacteristic? { - return getCharacteristicForServiceUUID(.CGMService, withUUIDString: UUID.rawValue) - } - - // MARK: - CBCentralManagerDelegate +extension BluetoothManager: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { + peripheralManager?.centralManagerDidUpdateState(central) + switch central.state { case .poweredOn: scanForPeripheral() @@ -364,133 +209,55 @@ class BluetoothManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate central.stopScan() } - let knownServiceUUIDs = peripheral.services?.flatMap({ $0.uuid }) ?? [] - - let servicesToDiscover = [ - CBUUID(string: TransmitterServiceUUID.CGMService.rawValue) - ].filter({ !knownServiceUUIDs.contains($0) }) + peripheralManager?.centralManager(central, didConnect: peripheral) - if servicesToDiscover.count > 0 { - log.info("Discovering services") - peripheral.discoverServices(servicesToDiscover) - } else { - self.peripheral(peripheral, didDiscoverServices: nil) + if case .poweredOn = manager.state, case .connected = peripheral.state { + self.delegate?.bluetoothManager(self, isReadyWithError: nil) } } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - if stayConnected { - scanAfterDelay() + // Ignore errors indicating the peripheral disconnected remotely, as that's expected behavior + if let error = error as NSError?, CBError(_nsError: error).code != .peripheralDisconnected { + log.error("%{public}@: %{public}@", #function, error) + self.delegate?.bluetoothManager(self, isReadyWithError: error) } - } - func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { if stayConnected { scanAfterDelay() } } - // MARK: - CBPeripheralDelegate - - func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - for service in peripheral.services ?? [] where service.uuid.uuidString == TransmitterServiceUUID.CGMService.rawValue { - var characteristicsToDiscover = [CBUUID]() - let knownCharacteristics = service.characteristics?.flatMap({ $0.uuid }) ?? [] - - switch TransmitterServiceUUID(rawValue: service.uuid.uuidString) { - case .CGMService?: - characteristicsToDiscover = [ - CBUUID(string: CGMServiceCharacteristicUUID.Communication.rawValue), - CBUUID(string: CGMServiceCharacteristicUUID.Authentication.rawValue), - CBUUID(string: CGMServiceCharacteristicUUID.Control.rawValue) - ] - case .ServiceB?: - break - default: - break - } - - characteristicsToDiscover = characteristicsToDiscover.filter({ !knownCharacteristics.contains($0) }) - - if characteristicsToDiscover.count > 0 { - log.info("Discovering characteristics") - peripheral.discoverCharacteristics(characteristicsToDiscover, for: service) - } else { - self.peripheral(peripheral, didDiscoverCharacteristicsFor: service, error: nil) - } - } - } - - func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { if let error = error { - log.error("Error discovering characteristics: %{public}@", String(describing: error)) + self.delegate?.bluetoothManager(self, isReadyWithError: error) } - self.delegate?.bluetoothManager(self, isReadyWithError: error) - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { - - operationLock.lock() - - if operationConditions.remove(.notificationStateUpdate(characteristic: characteristic, enabled: characteristic.isNotifying)) != nil { - operationError = error - - if operationConditions.isEmpty { - operationLock.broadcast() - } + if stayConnected { + scanAfterDelay() } - - operationLock.unlock() } +} - func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - operationLock.lock() - - if operationConditions.remove(.valueUpdate(characteristic: characteristic, firstByte: characteristic.value?[0])) != nil || - operationConditions.remove(.valueUpdate(characteristic: characteristic, firstByte: nil)) != nil - { - operationError = error - - if operationConditions.isEmpty { - operationLock.broadcast() - } - } - - operationLock.unlock() - if let data = characteristic.value { - delegate?.bluetoothManager(self, didReceiveControlResponse: data) - } +extension BluetoothManager: PeripheralManagerDelegate { + func peripheralManager(_ manager: PeripheralManager, didReadRSSI RSSI: NSNumber, error: Error?) { + } - func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { - - operationLock.lock() - - if operationConditions.remove(.writeUpdate(characteristic: characteristic)) != nil { - operationError = error + func peripheralManagerDidUpdateName(_ manager: PeripheralManager) { - if operationConditions.isEmpty { - operationLock.broadcast() - } - } - - operationLock.unlock() } -} + func completeConfiguration(for manager: PeripheralManager) throws { -private extension Array where Element: CBAttribute { + } - func itemWithUUIDString(_ UUIDString: String) -> Element? { - for attribute in self { - if attribute.uuid.uuidString == UUIDString { - return attribute - } + func peripheralManager(_ manager: PeripheralManager, didUpdateValueFor characteristic: CBCharacteristic) { + guard let value = characteristic.value else { + return } - return nil + self.delegate?.bluetoothManager(self, didReceiveControlResponse: value) } - } diff --git a/xDripG5/BluetoothServices.swift b/xDripG5/BluetoothServices.swift index 65fa8d58..62971632 100644 --- a/xDripG5/BluetoothServices.swift +++ b/xDripG5/BluetoothServices.swift @@ -6,6 +6,8 @@ // Copyright © 2015 Nathan Racklyeft. All rights reserved. // +import CoreBluetooth + /* G5 BLE attributes, retrieved using LightBlue on 2015-10-01 @@ -13,38 +15,63 @@ These are the G4 details, for reference: https://github.com/StephenBlackWasAlreadyTaken/xDrip/blob/af20e32652d19aa40becc1a39f6276cad187fdce/app/src/main/java/com/eveningoutpost/dexdrip/UtilityModels/DexShareAttributes.java */ -enum TransmitterServiceUUID: String { - case DeviceInfo = "180A" - case Advertisement = "FEBC" - case CGMService = "F8083532-849E-531C-C594-30F1F86A4EA5" +protocol CBUUIDRawValue: RawRepresentable {} +extension CBUUIDRawValue where RawValue == String { + var cbUUID: CBUUID { + return CBUUID(string: rawValue) + } +} + + +enum TransmitterServiceUUID: String, CBUUIDRawValue { + case deviceInfo = "180A" + case advertisement = "FEBC" + case cgmService = "F8083532-849E-531C-C594-30F1F86A4EA5" - case ServiceB = "F8084532-849E-531C-C594-30F1F86A4EA5" + case serviceB = "F8084532-849E-531C-C594-30F1F86A4EA5" } -enum DeviceInfoCharacteristicUUID: String { +enum DeviceInfoCharacteristicUUID: String, CBUUIDRawValue { // Read // "DexcomUN" - case ManufacturerNameString = "2A29" + case manufacturerNameString = "2A29" } -enum CGMServiceCharacteristicUUID: String { +enum CGMServiceCharacteristicUUID: String, CBUUIDRawValue { // Read/Notify - case Communication = "F8083533-849E-531C-C594-30F1F86A4EA5" + case communication = "F8083533-849E-531C-C594-30F1F86A4EA5" // Write/Indicate - case Control = "F8083534-849E-531C-C594-30F1F86A4EA5" + case control = "F8083534-849E-531C-C594-30F1F86A4EA5" // Read/Write/Indicate - case Authentication = "F8083535-849E-531C-C594-30F1F86A4EA5" + case authentication = "F8083535-849E-531C-C594-30F1F86A4EA5" // Read/Write/Notify - case ProbablyBackfill = "F8083536-849E-531C-C594-30F1F86A4EA5" + case probablyBackfill = "F8083536-849E-531C-C594-30F1F86A4EA5" } -enum ServiceBCharacteristicUUID: String { +enum ServiceBCharacteristicUUID: String, CBUUIDRawValue { // Write/Indicate - case CharacteristicE = "F8084533-849E-531C-C594-30F1F86A4EA5" + case characteristicE = "F8084533-849E-531C-C594-30F1F86A4EA5" // Read/Write/Notify - case CharacteristicF = "F8084534-849E-531C-C594-30F1F86A4EA5" + case characteristicF = "F8084534-849E-531C-C594-30F1F86A4EA5" +} + + +extension PeripheralManager.Configuration { + static var dexcomG5: PeripheralManager.Configuration { + return PeripheralManager.Configuration( + serviceCharacteristics: [ + TransmitterServiceUUID.cgmService.cbUUID: [ + CGMServiceCharacteristicUUID.communication.cbUUID, + CGMServiceCharacteristicUUID.authentication.cbUUID, + CGMServiceCharacteristicUUID.control.cbUUID + ] + ], + notifyingCharacteristics: [:], + valueUpdateMacros: [:] + ) + } } diff --git a/xDripG5/CBPeripheral.swift b/xDripG5/CBPeripheral.swift new file mode 100644 index 00000000..9e0f11ab --- /dev/null +++ b/xDripG5/CBPeripheral.swift @@ -0,0 +1,45 @@ +// +// CBPeripheral.swift +// xDripG5 +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import CoreBluetooth + + +// MARK: - Discovery helpers. +extension CBPeripheral { + func servicesToDiscover(from serviceUUIDs: [CBUUID]) -> [CBUUID] { + let knownServiceUUIDs = services?.flatMap({ $0.uuid }) ?? [] + return serviceUUIDs.filter({ !knownServiceUUIDs.contains($0) }) + } + + func characteristicsToDiscover(from characteristicUUIDs: [CBUUID], for service: CBService) -> [CBUUID] { + let knownCharacteristicUUIDs = service.characteristics?.flatMap({ $0.uuid }) ?? [] + return characteristicUUIDs.filter({ !knownCharacteristicUUIDs.contains($0) }) + } +} + + +extension Collection where Element: CBAttribute { + func itemWithUUID(_ uuid: CBUUID) -> Element? { + for attribute in self { + if attribute.uuid == uuid { + return attribute + } + } + + return nil + } + + func itemWithUUIDString(_ uuidString: String) -> Element? { + for attribute in self { + if attribute.uuid.uuidString == uuidString { + return attribute + } + } + + return nil + } +} diff --git a/xDripG5/Messages/AuthRequestTxMessage.swift b/xDripG5/Messages/AuthRequestTxMessage.swift index 8b27c5c5..d6bc73d6 100644 --- a/xDripG5/Messages/AuthRequestTxMessage.swift +++ b/xDripG5/Messages/AuthRequestTxMessage.swift @@ -15,11 +15,10 @@ struct AuthRequestTxMessage: TransmitterTxMessage { let endByte: UInt8 = 0x2 init() { - var UUIDBytes = [UInt8](repeating: 0, count: 16) + let uuid = UUID().uuid - NSUUID().getBytes(&UUIDBytes) - - singleUseToken = Data(bytes: UUIDBytes) + singleUseToken = Data(bytes: [uuid.0, uuid.1, uuid.2, uuid.3, + uuid.4, uuid.5, uuid.6, uuid.7]) } var byteSequence: [Any] { diff --git a/xDripG5/OSLog.swift b/xDripG5/OSLog.swift index b0902d98..a6dd4af9 100644 --- a/xDripG5/OSLog.swift +++ b/xDripG5/OSLog.swift @@ -9,6 +9,10 @@ import os.log extension OSLog { + convenience init(category: String) { + self.init(subsystem: "com.loopkit.xDripG5", category: category) + } + func debug(_ message: StaticString, _ args: CVarArg...) { log(message, type: .debug, args) } @@ -21,7 +25,18 @@ extension OSLog { log(message, type: .error, args) } - private func log(_ message: StaticString, type: OSLogType, _ args: CVarArg...) { - os_log(message, log: self, type: type, args) + private func log(_ message: StaticString, type: OSLogType, _ args: [CVarArg]) { + switch args.count { + case 0: + os_log(message, log: self, type: type) + case 1: + os_log(message, log: self, type: type, args[0]) + case 2: + os_log(message, log: self, type: type, args[0], args[1]) + case 3: + os_log(message, log: self, type: type, args[0], args[1], args[2]) + default: + os_log(message, log: self, type: type, args) + } } } diff --git a/xDripG5/PeripheralManager+G5.swift b/xDripG5/PeripheralManager+G5.swift new file mode 100644 index 00000000..915d309c --- /dev/null +++ b/xDripG5/PeripheralManager+G5.swift @@ -0,0 +1,111 @@ +// +// PeripheralManager+G5.swift +// xDripG5 +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import CoreBluetooth + + +extension PeripheralManager { + func setNotifyValue(_ enabled: Bool, + for characteristicUUID: CGMServiceCharacteristicUUID, + timeout: TimeInterval = 2) throws + { + guard let characteristic = peripheral.getCharacteristicWithUUID(characteristicUUID) else { + throw PeripheralManagerError.unknownCharacteristic + } + + try setNotifyValue(enabled, for: characteristic, timeout: timeout) + } + + func readValue( + for characteristicUUID: CGMServiceCharacteristicUUID, + timeout: TimeInterval = 2, + expectingFirstByte firstByte: UInt8? = nil) throws -> Data + { + guard let characteristic = peripheral.getCharacteristicWithUUID(characteristicUUID) else { + throw PeripheralManagerError.unknownCharacteristic + } + + try runCommand(timeout: timeout) { + addCondition(.makeValueUpdate(characteristic: characteristic, matchingFirstByte: firstByte)) + + peripheral.readValue(for: characteristic) + } + + guard let value = characteristic.value else { + // TODO: This is an "unknown value" issue, not a timeout + throw PeripheralManagerError.timeout + } + + return value + } + + func writeValue(_ value: Data, + for characteristicUUID: CGMServiceCharacteristicUUID, + type: CBCharacteristicWriteType = .withResponse, + timeout: TimeInterval = 2, + expectingFirstByte firstByte: UInt8? = nil) throws -> Data + { + guard let characteristic = peripheral.getCharacteristicWithUUID(characteristicUUID) else { + throw PeripheralManagerError.unknownCharacteristic + } + + try runCommand(timeout: timeout) { + if case .withResponse = type { + addCondition(.write(characteristic: characteristic)) + } + + if characteristic.isNotifying { + addCondition(.makeValueUpdate(characteristic: characteristic, matchingFirstByte: firstByte)) + } + + peripheral.writeValue(value, for: characteristic, type: type) + } + + guard let value = characteristic.value else { + // TODO: This is an "unknown value" issue, not a timeout + throw PeripheralManagerError.timeout + } + + return value + } +} + + +fileprivate extension PeripheralManager.CommandCondition { + static func makeValueUpdate(characteristic: CBCharacteristic, matchingFirstByte firstByte: UInt8?) -> PeripheralManager.CommandCondition { + return .valueUpdate(characteristic: characteristic, matching: { value in + if let firstByte = firstByte { + if let value = value, value.count > 0, value[0] == firstByte { + return true + } else { + return false + } + } else { // No condition on response + return true + } + }) + } +} + + +fileprivate extension CBPeripheral { + func getServiceWithUUID(_ uuid: TransmitterServiceUUID) -> CBService? { + return services?.itemWithUUIDString(uuid.rawValue) + } + + func getCharacteristicForServiceUUID(_ serviceUUID: TransmitterServiceUUID, withUUIDString UUIDString: String) -> CBCharacteristic? { + guard let characteristics = getServiceWithUUID(serviceUUID)?.characteristics else { + return nil + } + + return characteristics.itemWithUUIDString(UUIDString) + } + + func getCharacteristicWithUUID(_ uuid: CGMServiceCharacteristicUUID) -> CBCharacteristic? { + return getCharacteristicForServiceUUID(.cgmService, withUUIDString: uuid.rawValue) + } +} diff --git a/xDripG5/PeripheralManager.swift b/xDripG5/PeripheralManager.swift new file mode 100644 index 00000000..4b30df0d --- /dev/null +++ b/xDripG5/PeripheralManager.swift @@ -0,0 +1,435 @@ +// +// PeripheralManager.swift +// xDripG5 +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import CoreBluetooth +import Foundation +import os.log + + +class PeripheralManager: NSObject { + + private let log = OSLog(category: "PeripheralManager") + + /// + /// This is mutable, because CBPeripheral instances can seemingly become invalid, and need to be periodically re-fetched from CBCentralManager + var peripheral: CBPeripheral { + didSet { + guard oldValue !== peripheral else { + return + } + + log.error("Replacing peripheral reference %{public}@ -> %{public}@", oldValue, peripheral) + + oldValue.delegate = nil + peripheral.delegate = self + + queue.async { + self.needsConfiguration = true + } + } + } + + /// The dispatch queue used to serialize operations on the peripheral + let queue = DispatchQueue(label: "com.loopkit.PeripheralManager.queue", qos: .utility) + + /// The condition used to signal command completion + private let commandLock = NSCondition() + + /// The required conditions for the operation to complete + private var commandConditions = [CommandCondition]() + + /// Any error surfaced during the active operation + private var commandError: Error? + + unowned let central: CBCentralManager + + let configuration: Configuration + + // Confined to `queue` + private var needsConfiguration = true + + weak var delegate: PeripheralManagerDelegate? + + init(peripheral: CBPeripheral, configuration: Configuration, centralManager: CBCentralManager) { + self.peripheral = peripheral + self.central = centralManager + self.configuration = configuration + + super.init() + + peripheral.delegate = self + + assertConfiguration() + } +} + + +// MARK: - Nested types +extension PeripheralManager { + struct Configuration { + var serviceCharacteristics: [CBUUID: [CBUUID]] = [:] + var notifyingCharacteristics: [CBUUID: [CBUUID]] = [:] + var valueUpdateMacros: [CBUUID: (_ manager: PeripheralManager) -> Void] = [:] + } + + enum CommandCondition { + case notificationStateUpdate(characteristic: CBCharacteristic, enabled: Bool) + case valueUpdate(characteristic: CBCharacteristic, matching: ((Data?) -> Bool)?) + case write(characteristic: CBCharacteristic) + case discoverServices + case discoverCharacteristicsForService(serviceUUID: CBUUID) + } +} + +protocol PeripheralManagerDelegate: class { + func peripheralManager(_ manager: PeripheralManager, didUpdateValueFor characteristic: CBCharacteristic) + + func peripheralManager(_ manager: PeripheralManager, didReadRSSI RSSI: NSNumber, error: Error?) + + func peripheralManagerDidUpdateName(_ manager: PeripheralManager) + + func completeConfiguration(for manager: PeripheralManager) throws +} + + +// MARK: - Operation sequence management +extension PeripheralManager { + func configureAndRun(_ block: @escaping (_ manager: PeripheralManager) -> Void) -> (() -> Void) { + return { [unowned self] in + if !self.needsConfiguration && self.peripheral.services == nil { + self.log.error("Configured peripheral has no services. Reconfiguring…") + } + + if self.needsConfiguration || self.peripheral.services == nil { + do { + try self.applyConfiguration() + try self.delegate?.completeConfiguration(for: self) + self.needsConfiguration = false + } catch let error { + self.log.debug("Error applying configuration: %@", String(describing: error)) + // Will retry + } + } + + block(self) + } + } + + func perform(_ block: @escaping (_ manager: PeripheralManager) -> Void) { + queue.async(execute: configureAndRun(block)) + } + + private func assertConfiguration() { + perform { (_) in + // Intentionally empty to trigger configuration if necessary + } + } + + private func applyConfiguration(discoveryTimeout: TimeInterval = 2) throws { + try discoverServices(configuration.serviceCharacteristics.keys.map { $0 }, timeout: discoveryTimeout) + + for service in peripheral.services ?? [] { + guard let characteristics = configuration.serviceCharacteristics[service.uuid] else { + // Not all services may have characteristics + continue + } + + try discoverCharacteristics(characteristics, for: service, timeout: discoveryTimeout) + } + + for (serviceUUID, characteristicUUIDs) in configuration.notifyingCharacteristics { + guard let service = peripheral.services?.itemWithUUID(serviceUUID) else { + throw PeripheralManagerError.unknownCharacteristic + } + + for characteristicUUID in characteristicUUIDs { + guard let characteristic = service.characteristics?.itemWithUUID(characteristicUUID) else { + throw PeripheralManagerError.unknownCharacteristic + } + + guard !characteristic.isNotifying else { + continue + } + + try setNotifyValue(true, for: characteristic, timeout: discoveryTimeout) + } + } + } +} + + +// MARK: - Synchronous Commands +extension PeripheralManager { + /// - Throws: PeripheralManagerError + func runCommand(timeout: TimeInterval, command: () -> Void) throws { + // Prelude + dispatchPrecondition(condition: .onQueue(queue)) + guard central.state == .poweredOn && peripheral.state == .connected else { + throw PeripheralManagerError.notReady + } + + commandLock.lock() + + defer { + commandLock.unlock() + } + + guard commandConditions.isEmpty else { + throw PeripheralManagerError.notReady + } + + // Run + command() + + guard !commandConditions.isEmpty else { + // If the command didn't add any conditions, then finish immediately + return + } + + // Postlude + let signaled = commandLock.wait(until: Date(timeIntervalSinceNow: timeout)) + + defer { + commandError = nil + commandConditions = [] + } + + guard signaled else { + throw PeripheralManagerError.timeout + } + + if let error = commandError { + throw PeripheralManagerError.cbPeripheralError(error) + } + } + + /// It's illegal to call this without first acquiring the commandLock + /// + /// - Parameter condition: The condition to add + func addCondition(_ condition: CommandCondition) { + dispatchPrecondition(condition: .onQueue(queue)) + commandConditions.append(condition) + } + + func discoverServices(_ serviceUUIDs: [CBUUID], timeout: TimeInterval) throws { + let servicesToDiscover = peripheral.servicesToDiscover(from: serviceUUIDs) + + guard servicesToDiscover.count > 0 else { + return + } + + try runCommand(timeout: timeout) { + addCondition(.discoverServices) + + peripheral.discoverServices(serviceUUIDs) + } + } + + func discoverCharacteristics(_ characteristicUUIDs: [CBUUID], for service: CBService, timeout: TimeInterval) throws { + let characteristicsToDiscover = peripheral.characteristicsToDiscover(from: characteristicUUIDs, for: service) + + guard characteristicsToDiscover.count > 0 else { + return + } + + try runCommand(timeout: timeout) { + addCondition(.discoverCharacteristicsForService(serviceUUID: service.uuid)) + + peripheral.discoverCharacteristics(characteristicsToDiscover, for: service) + } + } + + /// - Throws: PeripheralManagerError + func setNotifyValue(_ enabled: Bool, for characteristic: CBCharacteristic, timeout: TimeInterval) throws { + try runCommand(timeout: timeout) { + addCondition(.notificationStateUpdate(characteristic: characteristic, enabled: enabled)) + + peripheral.setNotifyValue(enabled, for: characteristic) + } + } + + /// - Throws: PeripheralManagerError + func readValue(for characteristic: CBCharacteristic, timeout: TimeInterval) throws -> Data? { + try runCommand(timeout: timeout) { + addCondition(.valueUpdate(characteristic: characteristic, matching: nil)) + + peripheral.readValue(for: characteristic) + } + + return characteristic.value + } + + /// - Throws: PeripheralManagerError + func wait(for characteristic: CBCharacteristic, timeout: TimeInterval) throws -> Data { + try runCommand(timeout: timeout) { + addCondition(.valueUpdate(characteristic: characteristic, matching: nil)) + } + + guard let value = characteristic.value else { + throw PeripheralManagerError.timeout + } + + return value + } + + /// - Throws: PeripheralManagerError + func writeValue(_ value: Data, for characteristic: CBCharacteristic, type: CBCharacteristicWriteType, timeout: TimeInterval) throws { + try runCommand(timeout: timeout) { + if case .withResponse = type { + addCondition(.write(characteristic: characteristic)) + } + + peripheral.writeValue(value, for: characteristic, type: type) + } + } +} + + +// MARK: - Delegate methods executed on the central's queue +extension PeripheralManager: CBPeripheralDelegate { + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + commandLock.lock() + + if let index = commandConditions.index(where: { (condition) -> Bool in + if case .discoverServices = condition { + return true + } else { + return false + } + }) { + commandConditions.remove(at: index) + commandError = error + + if commandConditions.isEmpty { + commandLock.broadcast() + } + } + + commandLock.unlock() + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + commandLock.lock() + + if let index = commandConditions.index(where: { (condition) -> Bool in + if case .discoverCharacteristicsForService(serviceUUID: service.uuid) = condition { + return true + } else { + return false + } + }) { + commandConditions.remove(at: index) + commandError = error + + if commandConditions.isEmpty { + commandLock.broadcast() + } + } + + commandLock.unlock() + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + commandLock.lock() + + if let index = commandConditions.index(where: { (condition) -> Bool in + if case .notificationStateUpdate(characteristic: characteristic, enabled: characteristic.isNotifying) = condition { + return true + } else { + return false + } + }) { + commandConditions.remove(at: index) + commandError = error + + if commandConditions.isEmpty { + commandLock.broadcast() + } + } + + commandLock.unlock() + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + commandLock.lock() + + if let index = commandConditions.index(where: { (condition) -> Bool in + if case .write(characteristic: characteristic) = condition { + return true + } else { + return false + } + }) { + commandConditions.remove(at: index) + commandError = error + + if commandConditions.isEmpty { + commandLock.broadcast() + } + } + + commandLock.unlock() + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + commandLock.lock() + + if let index = commandConditions.index(where: { (condition) -> Bool in + if case .valueUpdate(characteristic: characteristic, matching: let matching) = condition { + return matching?(characteristic.value) ?? true + } else { + return false + } + }) { + commandConditions.remove(at: index) + commandError = error + + if commandConditions.isEmpty { + commandLock.broadcast() + } + } else if let macro = configuration.valueUpdateMacros[characteristic.uuid] { + macro(self) + } else if commandConditions.isEmpty { + defer { // execute after the unlock + // If we weren't expecting this notification, pass it along to the delegate + delegate?.peripheralManager(self, didUpdateValueFor: characteristic) + } + } + + commandLock.unlock() + } + + func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { + delegate?.peripheralManager(self, didReadRSSI: RSSI, error: error) + } + + func peripheralDidUpdateName(_ peripheral: CBPeripheral) { + delegate?.peripheralManagerDidUpdateName(self) + } +} + + +extension PeripheralManager: CBCentralManagerDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + switch central.state { + case .poweredOn: + assertConfiguration() + default: + break + } + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + switch peripheral.state { + case .connected: + assertConfiguration() + default: + break + } + } +} diff --git a/xDripG5/PeripheralManagerError.swift b/xDripG5/PeripheralManagerError.swift new file mode 100644 index 00000000..13bf1af0 --- /dev/null +++ b/xDripG5/PeripheralManagerError.swift @@ -0,0 +1,41 @@ +// +// PeripheralManagerError.swift +// xDripG5 +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import CoreBluetooth + + +enum PeripheralManagerError: Error { + case cbPeripheralError(Error) + case notReady + case timeout + case unknownCharacteristic +} + + +extension PeripheralManagerError: LocalizedError { + var errorDescription: String? { + switch self { + case .cbPeripheralError(let error): + return error.localizedDescription + case .notReady: + return NSLocalizedString("Peripheral isnʼt connected", comment: "Not ready error description") + case .timeout: + return NSLocalizedString("Peripheral did not respond in time", comment: "Timeout error description") + case .unknownCharacteristic: + return NSLocalizedString("Unknown characteristic", comment: "Error description") + } + } + + var failureReason: String? { + switch self { + case .cbPeripheralError(let error as NSError): + return error.localizedFailureReason + default: + return errorDescription + } + } +} diff --git a/xDripG5/Transmitter.swift b/xDripG5/Transmitter.swift index 1fe3f65a..1b48956c 100644 --- a/xDripG5/Transmitter.swift +++ b/xDripG5/Transmitter.swift @@ -29,7 +29,11 @@ public enum TransmitterError: Error { public final class Transmitter: BluetoothManagerDelegate { /// The ID of the transmitter to connect to - public var ID: String + public var ID: String { + return id.id + } + + private var id: TransmitterID /// The initial activation date of the transmitter public private(set) var activationDate: Date? @@ -40,18 +44,16 @@ public final class Transmitter: BluetoothManagerDelegate { public weak var delegate: TransmitterDelegate? - private let log: OSLog + private let log = OSLog(category: "Transmitter") private let bluetoothManager = BluetoothManager() - private var operationQueue = DispatchQueue(label: "com.loudnate.xDripG5.transmitterOperationQueue") + private var delegateQueue = DispatchQueue(label: "com.loudnate.xDripG5.delegateQueue", qos: .utility) - public init(ID: String, passiveModeEnabled: Bool = false) { - self.ID = ID + public init(id: String, passiveModeEnabled: Bool = false) { + self.id = TransmitterID(id: id) self.passiveModeEnabled = passiveModeEnabled - log = OSLog(subsystem: Bundle(for: Transmitter.self).bundleIdentifier!, category: "Transmitter") - bluetoothManager.delegate = self } @@ -86,50 +88,44 @@ public final class Transmitter: BluetoothManagerDelegate { func bluetoothManager(_ manager: BluetoothManager, isReadyWithError error: Error?) { if let error = error { - self.delegate?.transmitter(self, didError: error) + delegateQueue.async { + self.delegate?.transmitter(self, didError: error) + } return } - operationQueue.async { + manager.peripheralManager?.perform { (peripheral) in if self.passiveModeEnabled { do { - try self.listenToControl() + try peripheral.listenToControl() } catch let error { - self.delegate?.transmitter(self, didError: error) + self.delegateQueue.async { + self.delegate?.transmitter(self, didError: error) + } } } else { do { - try self.authenticate() - try self.control() + try peripheral.authenticate(id: self.id) + let glucose = try peripheral.control() + self.delegateQueue.async { + self.delegate?.transmitter(self, didRead: glucose) + } } catch let error { manager.disconnect() - self.delegate?.transmitter(self, didError: error) + self.delegateQueue.async { + self.delegate?.transmitter(self, didError: error) + } } } } } - /** - Convenience helper for getting a substring of the last two characters of a string. - - The Dexcom G5 advertises a peripheral name of "DexcomXX" where "XX" is the last-two characters - of the transmitter ID. - - - parameter string: The string to parse - - - returns: A new string, containing the last two characters of the input string - */ - private func lastTwoCharactersOfString(_ string: String) -> String { - #if swift(>=4) - return String(string[string.index(string.endIndex, offsetBy: -2)...]) - #else - return string.substring(from: string.characters.index(string.endIndex, offsetBy: -2, limitedBy: string.startIndex)!) - #endif - } - func bluetoothManager(_ manager: BluetoothManager, shouldConnectPeripheral peripheral: CBPeripheral) -> Bool { - if let name = peripheral.name , lastTwoCharactersOfString(name) == lastTwoCharactersOfString(ID) { + + /// The Dexcom G5 advertises a peripheral name of "DexcomXX" + /// where "XX" is the last-two characters of the transmitter ID. + if let name = peripheral.name, name.suffix(2) == id.id.suffix(2) { return true } else { return false @@ -147,7 +143,9 @@ public final class Transmitter: BluetoothManagerDelegate { let timeMessage = lastTimeMessage, let activationDate = activationDate { - self.delegate?.transmitter(self, didRead: Glucose(glucoseMessage: glucoseMessage, timeMessage: timeMessage, activationDate: activationDate)) + delegateQueue.async { + self.delegate?.transmitter(self, didRead: Glucose(glucoseMessage: glucoseMessage, timeMessage: timeMessage, activationDate: activationDate)) + } return } case CalibrationDataRxMessage.opcode, SessionStartRxMessage.opcode, SessionStopRxMessage.opcode: @@ -164,97 +162,119 @@ public final class Transmitter: BluetoothManagerDelegate { delegate?.transmitter(self, didReadUnknownData: response) } +} - // MARK: - Helpers - private func authenticate() throws { - if let data = try? bluetoothManager.readValueForCharacteristicAndWait(.Authentication), - let status = AuthStatusRxMessage(data: data), status.authenticated == 1 && status.bonded == 1 - { - NSLog("Transmitter already authenticated.") - } else { - do { - try bluetoothManager.setNotifyEnabledAndWait(true, forCharacteristicUUID: .Authentication) - } catch let error { - throw TransmitterError.authenticationError("Error enabling notification: \(error)") - } +struct TransmitterID { + let id: String - let authMessage = AuthRequestTxMessage() - let data: Data + init(id: String) { + self.id = id + } - do { - data = try bluetoothManager.writeValueAndWait(authMessage.data, forCharacteristicUUID: .Authentication, expectingFirstByte: AuthChallengeRxMessage.opcode) - } catch let error { - throw TransmitterError.authenticationError("Error writing transmitter challenge: \(error)") - } + private var cryptKey: Data? { + return "00\(id)00\(id)".data(using: .utf8) + } - guard let response = AuthChallengeRxMessage(data: data) else { - throw TransmitterError.authenticationError("Unable to parse auth challenge: \(data)") - } + func computeHash(of data: Data) -> Data? { + guard data.count == 8, let key = cryptKey else { + return nil + } - guard response.tokenHash == self.calculateHash(authMessage.singleUseToken) else { - throw TransmitterError.authenticationError("Transmitter failed auth challenge") - } + var doubleData = Data(capacity: data.count * 2) + doubleData.append(data) + doubleData.append(data) - if let challengeHash = self.calculateHash(response.challenge) { - let data: Data - do { - data = try bluetoothManager.writeValueAndWait(AuthChallengeTxMessage(challengeHash: challengeHash).data, forCharacteristicUUID: .Authentication, expectingFirstByte: AuthStatusRxMessage.opcode) - } catch let error { - throw TransmitterError.authenticationError("Error writing challenge response: \(error)") - } + guard let outData = try? AESCrypt.encryptData(doubleData, usingKey: key) else { + return nil + } - guard let response = AuthStatusRxMessage(data: data) else { - throw TransmitterError.authenticationError("Unable to parse auth status: \(data)") - } + return outData.subdata(in: 0..<8) + } +} - guard response.authenticated == 1 else { - throw TransmitterError.authenticationError("Transmitter rejected auth challenge") - } - if response.bonded != 0x1 { - do { - _ = try bluetoothManager.writeValueAndWait(KeepAliveTxMessage(time: 25).data, forCharacteristicUUID: .Authentication) - } catch let error { - throw TransmitterError.authenticationError("Error writing keep-alive for bond: \(error)") - } +// MARK: - Helpers +fileprivate extension PeripheralManager { + func authenticate(id: TransmitterID) throws { + let authMessage = AuthRequestTxMessage() + let authRequestRx: Data - let data: Data - do { - // Wait for the OS dialog to pop-up before continuing. - data = try bluetoothManager.writeValueAndWait(BondRequestTxMessage().data, forCharacteristicUUID: .Authentication, timeout: 15, expectingFirstByte: AuthStatusRxMessage.opcode) - } catch let error { - throw TransmitterError.authenticationError("Error writing bond request: \(error)") - } + do { + _ = try writeValue(authMessage.data, for: .authentication) + authRequestRx = try readValue(for: .authentication, expectingFirstByte: AuthChallengeRxMessage.opcode) + } catch let error { + throw TransmitterError.authenticationError("Error writing transmitter challenge: \(error)") + } - guard let response = AuthStatusRxMessage(data: data) else { - throw TransmitterError.authenticationError("Unable to parse auth status: \(data)") - } + guard let challengeRx = AuthChallengeRxMessage(data: authRequestRx) else { + throw TransmitterError.authenticationError("Unable to parse auth challenge: \(authRequestRx)") + } - guard response.bonded == 0x1 else { - throw TransmitterError.authenticationError("Transmitter failed to bond") - } - } - } + guard challengeRx.tokenHash == id.computeHash(of: authMessage.singleUseToken) else { + throw TransmitterError.authenticationError("Transmitter failed auth challenge") + } - do { - try bluetoothManager.setNotifyEnabledAndWait(false, forCharacteristicUUID: .Authentication) - } catch let error { - throw TransmitterError.authenticationError("Error disabling notification: \(error)") - } + guard let challengeHash = id.computeHash(of: challengeRx.challenge) else { + throw TransmitterError.authenticationError("Failed to compute challenge hash for transmitter ID") + } + + let statusData: Data + do { + _ = try writeValue(AuthChallengeTxMessage(challengeHash: challengeHash).data, for: .authentication) + statusData = try readValue(for: .authentication, expectingFirstByte: AuthStatusRxMessage.opcode) + } catch let error { + throw TransmitterError.authenticationError("Error writing challenge response: \(error)") + } + + guard let status = AuthStatusRxMessage(data: statusData) else { + throw TransmitterError.authenticationError("Unable to parse auth status: \(statusData)") + } + + guard status.authenticated == 1 else { + throw TransmitterError.authenticationError("Transmitter rejected auth challenge") + } + + if status.bonded != 0x1 { + try bond() + } + } + + private func bond() throws { + do { + _ = try writeValue(KeepAliveTxMessage(time: 25).data, for: .authentication) + } catch let error { + throw TransmitterError.authenticationError("Error writing keep-alive for bond: \(error)") + } + + let data: Data + do { + // Wait for the OS dialog to pop-up before continuing. + _ = try writeValue(BondRequestTxMessage().data, for: .authentication) + data = try readValue(for: .authentication, timeout: 15, expectingFirstByte: AuthStatusRxMessage.opcode) + } catch let error { + throw TransmitterError.authenticationError("Error writing bond request: \(error)") + } + + guard let response = AuthStatusRxMessage(data: data) else { + throw TransmitterError.authenticationError("Unable to parse auth status: \(data)") + } + + guard response.bonded == 0x1 else { + throw TransmitterError.authenticationError("Transmitter failed to bond") } } - private func control() throws { + func control() throws -> Glucose { do { - try bluetoothManager.setNotifyEnabledAndWait(true, forCharacteristicUUID: .Control) + try setNotifyValue(true, for: .control) } catch let error { throw TransmitterError.controlError("Error enabling notification: \(error)") } let timeData: Data do { - timeData = try bluetoothManager.writeValueAndWait(TransmitterTimeTxMessage().data, forCharacteristicUUID: .Control, expectingFirstByte: TransmitterTimeRxMessage.opcode) + timeData = try writeValue(TransmitterTimeTxMessage().data, for: .control, expectingFirstByte: TransmitterTimeRxMessage.opcode) } catch let error { throw TransmitterError.controlError("Error writing time request: \(error)") } @@ -267,7 +287,7 @@ public final class Transmitter: BluetoothManagerDelegate { let glucoseData: Data do { - glucoseData = try bluetoothManager.writeValueAndWait(GlucoseTxMessage().data, forCharacteristicUUID: .Control, expectingFirstByte: GlucoseRxMessage.opcode) + glucoseData = try writeValue(GlucoseTxMessage().data, for: .control, expectingFirstByte: GlucoseRxMessage.opcode) } catch let error { throw TransmitterError.controlError("Error writing glucose request: \(error)") } @@ -276,44 +296,23 @@ public final class Transmitter: BluetoothManagerDelegate { throw TransmitterError.controlError("Unable to parse glucose response: \(glucoseData)") } - // Update and notify - self.lastTimeMessage = timeMessage - self.activationDate = activationDate - self.delegate?.transmitter(self, didRead: Glucose(glucoseMessage: glucoseMessage, timeMessage: timeMessage, activationDate: activationDate)) - - do { - try bluetoothManager.setNotifyEnabledAndWait(false, forCharacteristicUUID: .Control) - _ = try bluetoothManager.writeValueAndWait(DisconnectTxMessage().data, forCharacteristicUUID: .Control) - } catch { + defer { + do { + try setNotifyValue(false, for: .control) + _ = try writeValue(DisconnectTxMessage().data, for: .control) + } catch { + } } + + // Update and notify + return Glucose(glucoseMessage: glucoseMessage, timeMessage: timeMessage, activationDate: activationDate) } - private func listenToControl() throws { + func listenToControl() throws { do { - try bluetoothManager.setNotifyEnabledAndWait(true, forCharacteristicUUID: .Control) + try setNotifyValue(true, for: .control) } catch let error { - log.error("Error enabling notification: %{public}@", String(describing: error)) throw TransmitterError.controlError("Error enabling notification: \(error)") } } - - private var cryptKey: Data? { - return "00\(ID)00\(ID)".data(using: .utf8) - } - - private func calculateHash(_ data: Data) -> Data? { - guard data.count == 8, let key = cryptKey else { - return nil - } - - var doubleData = Data(capacity: data.count * 2) - doubleData.append(data) - doubleData.append(data) - - guard let outData = try? AESCrypt.encryptData(doubleData, usingKey: key) else { - return nil - } - - return outData.subdata(in: 0..<8) - } } diff --git a/xDripG5Tests/TransmitterIDTests.swift b/xDripG5Tests/TransmitterIDTests.swift new file mode 100644 index 00000000..d8bedc36 --- /dev/null +++ b/xDripG5Tests/TransmitterIDTests.swift @@ -0,0 +1,20 @@ +// +// TransmitterIDTests.swift +// xDripG5Tests +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import XCTest +@testable import xDripG5 + +class TransmitterIDTests: XCTestCase { + + /// Sanity check the hash computation path + func testComputeHash() { + let id = TransmitterID(id: "123456") + + XCTAssertEqual("e60d4a7999b0fbb2", id.computeHash(of: Data(hexadecimalString: "0123456789abcdef")!)!.hexadecimalString) + } + +}