├── .gitignore ├── README.md ├── Tests ├── LinuxMain.swift └── FTMS BluetoothTests │ ├── XCTestManifests.swift │ └── FTMS_BluetoothTests.swift ├── Sources └── FTMS Bluetooth │ ├── FTMSUUIDs.swift │ ├── RowerData.swift │ ├── Utils │ ├── Fields.swift │ └── Subject.swift │ ├── Bluetooth Delegates │ ├── BLECentralMananger.swift │ └── FTMSPeripheralDelegate.swift │ └── BluetoothManager.swift ├── Package.swift └── .swiftpm └── xcode └── xcshareddata └── xcschemes └── FTMS Bluetooth.xcscheme /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FTMS Bluetooth 2 | 3 | Providing Bluetooth API for accessing Fitnes Machine Data 4 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import FTMS_BluetoothTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += FTMS_BluetoothTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/FTMS BluetoothTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(FTMS_BluetoothTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Tests/FTMS BluetoothTests/FTMS_BluetoothTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import FTMS_Bluetooth 3 | 4 | final class FTMS_BluetoothTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(FTMS_Bluetooth().text, "Hello, World!") 10 | } 11 | 12 | static var allTests = [ 13 | ("testExample", testExample), 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /Sources/FTMS Bluetooth/FTMSUUIDs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Gerry Weißbach on 18.09.19. 6 | // 7 | 8 | import CoreBluetooth 9 | 10 | public enum FTMSUUID : String { 11 | 12 | case serviceFTMS = "1826" 13 | 14 | case characteristicFTMSFeature = "2ACC" 15 | case characteristicFTMSStatus = "2ADA" 16 | case characteristicFTMSControlPoint = "2AD9" 17 | 18 | case characteristicRowerData = "2AD1" 19 | case characteristicTrainingStatus = "2AD3" 20 | case characteristicInclinationRange = "2AD5" 21 | case characteristicResistenceLevelRange = "2AD6" 22 | case characteristicHeartRateRange = "2AD7" 23 | case characteristicPowerRange = "2AD8" 24 | 25 | var uuid: CBUUID { 26 | return CBUUID( string: self.rawValue ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/FTMS Bluetooth/RowerData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RowerData.swift 3 | // Object providing observability 4 | // 5 | // Created by Gerry Weißbach on 29.09.19. 6 | // 7 | 8 | import Foundation 9 | 10 | public class RowerData { 11 | public var strokeRate: UInt8 = 0 12 | public var strokeCount: UInt16 = 0 13 | public var averageStrokeRate: UInt8 = 0 14 | public var totalDistance: UInt32 = 0 15 | public var instantaneousPace: UInt16 = 0 16 | public var averagePace: UInt16 = 0 17 | public var instantaneousPower: UInt16 = 0 18 | public var averagePower: Int16 = 0 19 | public var resistenceLevel: Int16 = 0 20 | public var totalEnergy: UInt16 = 0 21 | public var energyPerHour: UInt16 = 0 22 | public var energyPerMinute: UInt8 = 0 23 | public var heartRate: UInt8 = 0 24 | public var metabolicEquivilent: UInt8 = 0 25 | public var elapsedTime: UInt16 = 0 26 | public var remainingTime: UInt16 = 0 27 | } 28 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "FTMS-Bluetooth", 8 | products: [ 9 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 10 | .library( 11 | name: "FTMS-Bluetooth", 12 | targets: ["FTMS Bluetooth"]), 13 | ], 14 | dependencies: [ 15 | // Dependencies declare other packages that this package depends on. 16 | // .package(url: /* package url */, from: "1.0.0"), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 20 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 21 | .target( 22 | name: "FTMS Bluetooth", 23 | dependencies: []), 24 | .testTarget( 25 | name: "FTMS BluetoothTests", 26 | dependencies: ["FTMS Bluetooth"]), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /Sources/FTMS Bluetooth/Utils/Fields.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Fields.swift 3 | // Read data of varying sizes from given data object. 4 | // Specifically for reading FTMS data objects. 5 | // 6 | // see https://github.com/svenmeier/coxswain/blob/72d9a806c4a6f7bef2dbfaa415871284d5f3a19d/app/src/main/java/svenmeier/coxswain/bluetooth/Fields.java 7 | // 8 | // Created by Gerry Weißbach on 27.09.19. 9 | // 10 | 11 | import Foundation 12 | 13 | extension FixedWidthInteger { 14 | var byteWidth:Int { 15 | return self.bitWidth/UInt8.bitWidth 16 | } 17 | static var byteWidth:Int { 18 | return Self.bitWidth/UInt8.bitWidth 19 | } 20 | } 21 | 22 | public class Fields { 23 | 24 | public static var UINT8 = UInt8.self.byteWidth 25 | public static var UINT16 = UInt16.self.byteWidth 26 | public static var UINT32 = UInt32.self.byteWidth 27 | public static var SINT16 = Int16.self.byteWidth 28 | 29 | var flags: UInt16 = 0 30 | 31 | var data: NSData 32 | 33 | var offset = 0 34 | 35 | // Init and read flags 36 | init( _ data: NSData, flagSize: Int ) { 37 | self.data = data; 38 | self.flags = get( Fields.UINT16 ) 39 | } 40 | 41 | // Return the bool value of the given flag number. 42 | public func flag( _ bit: Int) -> Bool { 43 | return (flags & (1 << bit)) != 0; 44 | } 45 | 46 | // Read the fields. Always going forward, should be used only once per read cycle 47 | public func get( _ format: Int ) -> T { 48 | 49 | var value: T = NSNumber(0) as! T 50 | self.data.getBytes(&value, range: NSRange.init(location: offset, length: format)) 51 | offset += format & 0xf 52 | 53 | return value 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/FTMS Bluetooth/Bluetooth Delegates/BLECentralMananger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BLECentralMananger.swift 3 | // FTMS Rower WatchKit Extension 4 | // 5 | // Created by Gerry Weißbach on 16.09.19. 6 | // Copyright © 2019 Gerry Weißbach. All rights reserved. 7 | // 8 | 9 | import CoreBluetooth 10 | 11 | @available(iOS 10.0, *) 12 | class BLECentralMananger: NSObject, CBCentralManagerDelegate { 13 | 14 | func centralManagerDidUpdateState(_ central: CBCentralManager) { 15 | switch central.state { 16 | case .unknown: 17 | print("[BluetoothManager] state: unknown") 18 | break 19 | case .resetting: 20 | print("[BluetoothManager] state: resetting") 21 | break 22 | case .unsupported: 23 | print("[BluetoothManager] state: not available") 24 | break 25 | case .unauthorized: 26 | print("[BluetoothManager] state: not authorized") 27 | break 28 | case .poweredOff: 29 | print("[BluetoothManager] state: powered off") 30 | BluetoothManager.stopScanningForFTMS() 31 | break 32 | case .poweredOn: 33 | print("[BluetoothManager] state: powered on") 34 | break 35 | @unknown default: 36 | print("[BluetoothManager] state: unknown") 37 | } 38 | 39 | BluetoothManager.isReady.value = (central.state == .poweredOn) 40 | } 41 | 42 | func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { 43 | 44 | print("[BluetoothManager] did connect:", peripheral.name ?? "") 45 | peripheral.discoverServices([FTMSUUID.serviceFTMS.uuid]) 46 | } 47 | 48 | func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { 49 | 50 | print("[BluetoothManager] did discover:", peripheral) 51 | BluetoothManager.stopScanningForFTMS(); 52 | BluetoothManager.connectPeripheral(peripheral: peripheral) 53 | } 54 | 55 | func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { 56 | 57 | print("[BluetoothManager] did disconnect:", peripheral.name ?? "") 58 | } 59 | 60 | func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { 61 | 62 | print("[BluetoothManager] did fail to connect:", peripheral.name ?? "") 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/FTMS Bluetooth.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 54 | 60 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Sources/FTMS Bluetooth/Utils/Subject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Subject.swift 3 | // Pods 4 | // 5 | // Created by Jesse Curry on 11/2/15. 6 | // 7 | // Big thanks to Daniel Tartaglia 8 | // https://gist.github.com/dtartaglia/0a71423c578f2bf3b15c 9 | // 10 | 11 | import Foundation 12 | 13 | public protocol Disposable { 14 | func dispose() 15 | } 16 | 17 | /** 18 | Wraps a variable to allow notifications upon value changes. 19 | */ 20 | public class Subject { 21 | 22 | /** 23 | The value that is to be observered. 24 | All current observers will be notified when it is assigned to. 25 | */ 26 | public var value: T { 27 | didSet { 28 | notify() 29 | } 30 | } 31 | 32 | /** 33 | Returns true if there are currently observers. 34 | */ 35 | public var isObserved:Bool { get { return observers.count > 0 } } 36 | 37 | init(value: T) { 38 | self.value = value 39 | } 40 | 41 | deinit { 42 | disposable?.dispose() 43 | } 44 | 45 | /** 46 | To observe changes in the subject, attach a block. 47 | When you want observation to end, call `dispose` on the returned Disposable 48 | */ 49 | public func attach(observer: @escaping (T) -> Void) -> Disposable { 50 | self.attach(observer: observer, updateOnMainThread: false) 51 | } 52 | 53 | public func attach(observer: @escaping (T) -> Void, updateOnMainThread: Bool) -> Disposable { 54 | let wrapped = ObserverWrapper(subject: self, function: observer, updateOnMainThread: updateOnMainThread) 55 | observers.append(wrapped) 56 | 57 | // Notify once on attachment 58 | wrapped.update(value: value) 59 | 60 | return wrapped 61 | } 62 | 63 | func map(transform: @escaping (T) -> U) -> Subject { 64 | let result: Subject = Subject(value: transform(value)) 65 | result.disposable = self.attach { [weak result] value in 66 | result?.value = transform(value) 67 | } 68 | 69 | return result 70 | } 71 | 72 | fileprivate func detach(wrappedObserver: ObserverWrapper) { 73 | observers = observers.filter { $0 !== wrappedObserver } 74 | } 75 | 76 | public func notify() { 77 | for observer in observers { 78 | observer.update(value: value) 79 | } 80 | } 81 | 82 | private var disposable: Disposable? 83 | private var observers: [ObserverWrapper] = [] 84 | 85 | } 86 | 87 | // MARK: - 88 | private class ObserverWrapper: Disposable { 89 | 90 | var updateOnMainThread = false 91 | 92 | init(subject: Subject, function: @escaping (T) -> Void, updateOnMainThread: Bool) { 93 | self.subject = subject 94 | self.function = function 95 | self.updateOnMainThread = updateOnMainThread 96 | } 97 | 98 | func update(value: T) { 99 | if ( updateOnMainThread ) { 100 | DispatchQueue.main.async { 101 | self.function(value) 102 | } 103 | } else { 104 | function(value) 105 | } 106 | } 107 | 108 | func dispose() { 109 | subject.detach(wrappedObserver: self) 110 | } 111 | 112 | unowned let subject: Subject 113 | let function: (T) -> Void 114 | } 115 | -------------------------------------------------------------------------------- /Sources/FTMS Bluetooth/BluetoothManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BluetoothManager.swift 3 | // FTMS Rower WatchKit Extension 4 | // 5 | // Created by Gerry Weißbach on 16.09.19. 6 | // Copyright © 2019 Gerry Weißbach. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import CoreBluetooth 11 | 12 | @available(iOS 10.0, *) 13 | public class BluetoothManager: NSObject, ObservableObject { 14 | 15 | public static let sharedInstance = BluetoothManager() 16 | 17 | 18 | public static func scanForFTMS() { 19 | sharedInstance.scanForFTMS() 20 | } 21 | 22 | public static func stopScanningForFTMS() { 23 | sharedInstance.stopScanningForFTMS() 24 | } 25 | 26 | public static func connectPeripheral( peripheral: CBPeripheral ) { 27 | sharedInstance.connectFTMS( peripheral: peripheral ) 28 | } 29 | 30 | public static var isReady : Subject { 31 | get { 32 | return sharedInstance.isReady 33 | } 34 | } 35 | 36 | public static var isConnected : Subject { 37 | get { 38 | return sharedInstance.isConnected 39 | } 40 | } 41 | 42 | public static var rowerData : Subject { 43 | get { 44 | return sharedInstance.peripheralDelegate.rowerData 45 | } 46 | } 47 | 48 | // MARK: Internal functions 49 | fileprivate let isReady = Subject(value: false) 50 | fileprivate let isConnected = Subject(value: false) 51 | 52 | fileprivate let connectedComModule = Subject(value: nil) 53 | fileprivate let peripheralDelegate = FTMSPeripheralDelegate() 54 | 55 | fileprivate var centralManager:CBCentralManager 56 | fileprivate var centralManagerDelegate:CBCentralManagerDelegate 57 | fileprivate let centralManagerQueue = DispatchQueue( 58 | label: "de.gammaproduction.s4commodule.bluetooth.central", 59 | attributes: DispatchQueue.Attributes.concurrent 60 | ) 61 | 62 | 63 | override private init( ) { 64 | // Perform any final initialization of your application. 65 | centralManagerDelegate = BLECentralMananger(); 66 | centralManager = CBCentralManager(delegate: centralManagerDelegate, queue: centralManagerQueue) 67 | 68 | self.isReady.value = (centralManager.state == CBManagerState.poweredOn) 69 | } 70 | 71 | func scanForFTMS() { 72 | centralManager.scanForPeripherals(withServices: [FTMSUUID.serviceFTMS.uuid], options: nil) 73 | } 74 | 75 | func stopScanningForFTMS() { 76 | centralManager.stopScan() 77 | } 78 | 79 | func connectFTMS( peripheral: CBPeripheral ) { 80 | peripheral.delegate = peripheralDelegate 81 | connectedComModule.value = peripheral 82 | isConnected.value = true 83 | centralManager.connect(connectedComModule.value!, options: nil) 84 | } 85 | 86 | func disconnectFTMS() { 87 | 88 | print("[FTMS] cleanup:") 89 | if let comModule = connectedComModule.value { 90 | comModule.services?.forEach({ (service:CBService) -> () in 91 | service.characteristics?.forEach({ (characteristic:CBCharacteristic) -> () in 92 | comModule.setNotifyValue(false, for: characteristic) 93 | }) 94 | }) 95 | 96 | centralManager.cancelPeripheralConnection(comModule) 97 | } 98 | 99 | isConnected.value = false 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/FTMS Bluetooth/Bluetooth Delegates/FTMSPeripheralDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Gerry Weißbach on 18.09.19. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import CoreBluetooth 11 | 12 | class FTMSPeripheralDelegate : NSObject, CBPeripheralDelegate { 13 | 14 | var rowerData = Subject(value: RowerData()) 15 | 16 | func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { 17 | print( "[CBPeripheralDelegate] did discover FTMS service", peripheral.services! ) 18 | peripheral.discoverCharacteristics(nil, for: peripheral.services![0]) 19 | } 20 | 21 | func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { 22 | print( "[CBPeripheralDelegate] did discover FTMS characteristic", service.characteristics ?? "" ) 23 | 24 | service.characteristics?.forEach( { c in 25 | peripheral.setNotifyValue(true, for: c) 26 | } ) 27 | } 28 | 29 | func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor descriptor: CBDescriptor, error: Error?) { 30 | print( "[CBPeripheralDelegate] did update value", descriptor ) 31 | } 32 | 33 | func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { 34 | 35 | switch( characteristic.uuid ) { 36 | case FTMSUUID.characteristicRowerData.uuid: 37 | 38 | guard let data = characteristic.value else { return } 39 | let fields = Fields( data as NSData, flagSize: Fields.UINT16 ) 40 | 41 | if (fields.flag(0) == false) { // more data 42 | rowerData.value.strokeRate = fields.get(Fields.UINT8) / 2; // stroke rate 0.5 43 | rowerData.value.strokeCount = fields.get(Fields.UINT16); // stroke count 44 | } 45 | if (fields.flag(1)) { 46 | rowerData.value.averageStrokeRate = fields.get(Fields.UINT8); // average stroke rate 47 | } 48 | if (fields.flag(2)) { 49 | rowerData.value.totalDistance = fields.get(Fields.UINT16) + 50 | (fields.get(Fields.UINT8) << 16); // total distance 51 | } 52 | if (fields.flag(3)) { 53 | rowerData.value.instantaneousPace = fields.get(Fields.UINT16); // instantaneous pace 54 | } 55 | if (fields.flag(4)) { 56 | rowerData.value.averagePace = fields.get(Fields.UINT16); // average pace 57 | } 58 | if (fields.flag(5)) { 59 | rowerData.value.instantaneousPower = fields.get(Fields.SINT16); // instantaneous power 60 | } 61 | if (fields.flag(6)) { 62 | rowerData.value.averagePower = fields.get(Fields.SINT16); // average power 63 | } 64 | if (fields.flag(7)) { 65 | rowerData.value.resistenceLevel = fields.get(Fields.SINT16); // resistance level 66 | } 67 | if (fields.flag(8)) { // expended energy 68 | rowerData.value.totalEnergy = fields.get(Fields.UINT16); // total energy 69 | rowerData.value.energyPerHour = fields.get(Fields.UINT16); // energy per hour 70 | rowerData.value.energyPerMinute = fields.get(Fields.UINT8); // energy per minute 71 | } 72 | if (fields.flag(9)) { 73 | let heartRate_ : UInt8 = fields.get(Fields.UINT8); // heart rate 74 | if (heartRate_ > 0) { 75 | rowerData.value.heartRate = heartRate_; 76 | } 77 | } 78 | if (fields.flag(10)) { 79 | rowerData.value.metabolicEquivilent = fields.get(Fields.UINT8) * 10; // metabolic equivalent 0.1 kcal 80 | } 81 | if (fields.flag(11)) { 82 | let elapsedTime_ : UInt16 = fields.get(Fields.UINT16); // elapsed time 83 | let delta = elapsedTime_ - rowerData.value.elapsedTime; 84 | // erroneous values are sent on minute boundaries, so ignore these deltas 85 | if (delta == 59 || delta == 60) { 86 | // 359 ... 300 ... 360 87 | // 599 ... 659 ... 600 88 | print("bluetooth rower erroneous elapsed time %s, duration is %s", elapsedTime_, rowerData.value.elapsedTime) 89 | } else { 90 | rowerData.value.elapsedTime = elapsedTime_; 91 | } 92 | } 93 | if (fields.flag(12)) { 94 | rowerData.value.remainingTime = fields.get(Fields.UINT16); // remaining time 95 | } 96 | 97 | rowerData.notify() 98 | 99 | break; 100 | 101 | default: 102 | 103 | guard let data = characteristic.value else { return } 104 | print( "[STATUS]", characteristic.uuid, data) 105 | break; 106 | } 107 | } 108 | } 109 | --------------------------------------------------------------------------------