├── iOS Example ├── iOS Example │ ├── Assets.xcassets │ │ ├── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── UI │ │ ├── Devices │ │ │ ├── Device.swift │ │ │ ├── DevicesViewModel.swift │ │ │ ├── DeviceListView.swift │ │ │ └── DevicesView.swift │ │ └── DeviceDetails │ │ │ ├── ConnectionStatus.swift │ │ │ ├── DeviceDetailsViewModel.swift │ │ │ └── DeviceDetailsView.swift │ ├── Util │ │ └── Cancellable.swift │ ├── Bluetooth │ │ ├── Connection │ │ │ ├── CBConnectionState.swift │ │ │ ├── CBConnectionFactory.swift │ │ │ ├── CBConnectionStateListener.swift │ │ │ ├── CBConnectedDevice.swift │ │ │ └── CBConnection.swift │ │ ├── Discovery │ │ │ ├── CBScanResult.swift │ │ │ └── CBScanner.swift │ │ └── RowerData │ │ │ └── ConnectedRowerDataDevice.swift │ ├── iOS Example.entitlements │ ├── AppDelegate.swift │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── SceneDelegate.swift │ └── Info.plist ├── iOS Example.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── project.pbxproj └── iOS Example.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Sources └── WaterRowerData-BLE │ ├── FTMS_v1.0.pdf │ ├── Internal │ ├── GattSpecification │ │ ├── Requirement.swift │ │ ├── Field.swift │ │ ├── BitRequirement.swift │ │ ├── Format.swift │ │ └── Data+ReadFormat.swift │ └── RowerDataSpecification │ │ ├── RowerDataFlagsField.swift │ │ ├── RowerDataHeartRateField.swift │ │ ├── RowerDataStrokeRateField.swift │ │ ├── RowerDataAveragePaceField.swift │ │ ├── RowerDataElapsedTimeField.swift │ │ ├── RowerDataStrokeCountField.swift │ │ ├── RowerDataTotalEnergyField.swift │ │ ├── RowerDataAveragePowerField.swift │ │ ├── RowerDataEnergyPerHourField.swift │ │ ├── RowerDataRemainingTimeField.swift │ │ ├── RowerDataTotalDistanceField.swift │ │ ├── RowerDataEnergyPerMinuteField.swift │ │ ├── RowerDataResistanceLevelField.swift │ │ ├── RowerDataAverageStrokeRateField.swift │ │ ├── RowerDataInstantaneousPaceField.swift │ │ ├── RowerDataInstantaneousPowerField.swift │ │ └── RowerDataMetabolicEquivalentField.swift │ ├── FitnessMachineService.swift │ ├── Info.plist │ ├── README.md │ ├── RowerData.swift │ └── RowerDataCharacteristic.swift ├── .idea ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml └── inspectionProfiles │ └── Project_Default.xml ├── WaterRowerData-iOS.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings ├── WaterRowerData_BLE_Info.plist ├── WaterRowerData_iOS_Info.plist ├── WaterRowerData_BLETests_Info.plist ├── WaterRowerData_iOSTests_Info.plist ├── xcshareddata │ └── xcschemes │ │ └── WaterRowerData-iOS-Package.xcscheme └── project.pbxproj ├── .gitignore ├── .github └── workflows │ └── test.yml ├── WaterRowerData-BLE.podspec ├── Package.swift ├── .swiftlint.yml ├── RELEASING.md ├── Tests └── WaterRowerData-BLETests │ ├── Info.plist │ ├── CharacteristicData.swift │ ├── CharacteristicFlags.swift │ ├── RowerDataCharacteristicFlags.swift │ └── RowerDataCharacteristicTest.swift ├── README.md └── LICENSE /iOS Example/iOS Example/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "author": "xcode", 4 | "version": 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/FTMS_v1.0.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gamma/WaterRowerData-iOS/master/Sources/WaterRowerData-BLE/FTMS_v1.0.pdf -------------------------------------------------------------------------------- /iOS Example/iOS Example/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "author": "xcode", 4 | "version": 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/UI/Devices/Device.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Device: Identifiable { 4 | 5 | let id: UUID 6 | let name: String 7 | } 8 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/GattSpecification/Requirement.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol Requirement { 4 | 5 | func check(in data: Data) -> Bool 6 | } 7 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /WaterRowerData-iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/Util/Cancellable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol Cancellable { 4 | 5 | func cancel() 6 | } 7 | 8 | class Cancelled: Cancellable { 9 | 10 | func cancel() { 11 | 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/GattSpecification/Field.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol Field { 4 | 5 | var name: String { get } 6 | var format: Format { get } 7 | 8 | func isPresent(in data: Data) -> Bool 9 | } 10 | -------------------------------------------------------------------------------- /iOS Example/iOS Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/UI/DeviceDetails/ConnectionStatus.swift: -------------------------------------------------------------------------------- 1 | enum ConnectionStatus: String { 2 | 3 | case disconnected = "Disconnected" 4 | case connecting = "Connecting" 5 | case connected = "Connected" 6 | case failed = "Failed" 7 | } 8 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/Bluetooth/Connection/CBConnectionState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum CBConnectionState { 4 | 5 | case disconnected 6 | case connecting 7 | case connected(device: CBConnectedDevice) 8 | case failed 9 | } 10 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/Bluetooth/Discovery/CBScanResult.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreBluetooth 3 | 4 | struct CBScanResult { 5 | 6 | let peripheral: CBPeripheral 7 | let advertisementData: [String: Any] 8 | let rssi: NSNumber 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # User settings 2 | xcuserdata/ 3 | 4 | # Build files 5 | build/ 6 | .build/ 7 | 8 | # Pod files 9 | Pods/ 10 | 11 | # IDEA files 12 | .idea/* 13 | !.idea/codeStyles 14 | !.idea/inspectionProfiles 15 | 16 | # MacOS files 17 | .DS_Store 18 | 19 | # Misc 20 | *.swp 21 | -------------------------------------------------------------------------------- /iOS Example/iOS Example.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /iOS Example/iOS Example.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /WaterRowerData-iOS.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /iOS Example/iOS Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/FitnessMachineService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /** 4 | Describes the Fitness Machine Service. 5 | */ 6 | public struct FitnessMachineService { 7 | 8 | /** 9 | The UUID value that identifies this service. 10 | */ 11 | public static let uuid = UUID(uuidString: "00001826-0000-1000-8000-00805F9B34FB")! 12 | } 13 | -------------------------------------------------------------------------------- /WaterRowerData-iOS.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded 6 | 7 | 8 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataFlagsField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataFlagsField: Field = RowerDataFlagsField() 4 | 5 | private struct RowerDataFlagsField: Field { 6 | 7 | var name = "Flags" 8 | var format: Format = .UInt16 9 | 10 | func isPresent(in data: Data) -> Bool { 11 | return true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/UI/Devices/DevicesViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class DevicesViewModel: ObservableObject { 4 | 5 | @Published var devices = [Device]() 6 | 7 | func append(_ device: Device) { 8 | if !devices.contains(where: { d in 9 | d.id == device.id 10 | }) { 11 | devices.append(device) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/GattSpecification/BitRequirement.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct BitRequirement: Requirement { 4 | 5 | let bitIndex: Int 6 | let bitValue: Int 7 | 8 | func check(in data: Data) -> Bool { 9 | let flagsValue = data.readIntValue(format: .UInt16, offset: 0) 10 | return flagsValue & (1 << bitIndex) == (bitValue << bitIndex) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/iOS Example.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.device.bluetooth 6 | 7 | com.apple.security.app-sandbox 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/UI/Devices/DeviceListView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct DeviceListView: View { 4 | 5 | @Binding var devices: [Device] 6 | 7 | var body: some View { 8 | List(devices) { device in 9 | NavigationLink( 10 | destination: DeviceDetailsView(viewModel: DeviceDetailsViewModel(device)) 11 | ) { 12 | Text(device.name) 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | test: 7 | runs-on: macOS-latest 8 | 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v2 12 | - name: Swift Test 13 | run: swift test 14 | - name: Pod lib lint 15 | run: pod lib lint --verbose 16 | - name: XCodeBuild 17 | run: xcodebuild -workspace iOS\ Example/iOS\ Example.xcworkspace -scheme "iOS Example" build CODE_SIGNING_REQUIRED=NO 18 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataHeartRateField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataHeartRateField: Field = RowerDataHeartRateField() 4 | 5 | private struct RowerDataHeartRateField: Field { 6 | 7 | var name = "Heart Rate" 8 | var format: Format = .UInt8 9 | 10 | private let requirement = BitRequirement(bitIndex: 9, bitValue: 1) 11 | 12 | func isPresent(in data: Data) -> Bool { 13 | return requirement.check(in: data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataStrokeRateField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataStrokeRateField: Field = RowerDataStrokeRateField() 4 | 5 | private struct RowerDataStrokeRateField: Field { 6 | 7 | var name = "Stroke Rate" 8 | var format: Format = .UInt8 9 | 10 | private let requirement = BitRequirement(bitIndex: 0, bitValue: 0) 11 | 12 | func isPresent(in data: Data) -> Bool { 13 | return requirement.check(in: data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataAveragePaceField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataAveragePaceField: Field = RowerDataAveragePaceField() 4 | 5 | private struct RowerDataAveragePaceField: Field { 6 | 7 | var name = "Average Pace" 8 | var format: Format = .UInt16 9 | 10 | private let requirement = BitRequirement(bitIndex: 4, bitValue: 1) 11 | 12 | func isPresent(in data: Data) -> Bool { 13 | return requirement.check(in: data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataElapsedTimeField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataElapsedTimeField: Field = RowerDataElapsedTimeField() 4 | 5 | private struct RowerDataElapsedTimeField: Field { 6 | 7 | var name = "Elapsed Time" 8 | var format: Format = .UInt16 9 | 10 | private let requirement = BitRequirement(bitIndex: 11, bitValue: 1) 11 | 12 | func isPresent(in data: Data) -> Bool { 13 | return requirement.check(in: data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataStrokeCountField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataStrokeCountField: Field = RowerDataStrokeCountField() 4 | 5 | private struct RowerDataStrokeCountField: Field { 6 | 7 | var name = "Stroke Count" 8 | var format: Format = .UInt16 9 | 10 | private let requirement = BitRequirement(bitIndex: 0, bitValue: 0) 11 | 12 | func isPresent(in data: Data) -> Bool { 13 | return requirement.check(in: data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataTotalEnergyField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataTotalEnergyField: Field = RowerDataTotalEnergyField() 4 | 5 | private struct RowerDataTotalEnergyField: Field { 6 | 7 | var name = "Total Energy" 8 | var format: Format = .UInt16 9 | 10 | private let requirement = BitRequirement(bitIndex: 8, bitValue: 1) 11 | 12 | func isPresent(in data: Data) -> Bool { 13 | return requirement.check(in: data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataAveragePowerField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataAveragePowerField: Field = RowerDataAveragePowerField() 4 | 5 | private struct RowerDataAveragePowerField: Field { 6 | 7 | var name = "Average Power" 8 | var format: Format = .SInt16 9 | 10 | private let requirement = BitRequirement(bitIndex: 6, bitValue: 1) 11 | 12 | func isPresent(in data: Data) -> Bool { 13 | return requirement.check(in: data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataEnergyPerHourField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataEnergyPerHourField: Field = RowerDataEnergyPerHourField() 4 | 5 | private struct RowerDataEnergyPerHourField: Field { 6 | 7 | var name = "Energy Per Hour" 8 | var format: Format = .UInt16 9 | 10 | private let requirement = BitRequirement(bitIndex: 8, bitValue: 1) 11 | 12 | func isPresent(in data: Data) -> Bool { 13 | return requirement.check(in: data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataRemainingTimeField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataRemainingTimeField: Field = RowerDataRemainingTimeField() 4 | 5 | private struct RowerDataRemainingTimeField: Field { 6 | 7 | var name = "Remaining Time" 8 | var format: Format = .UInt16 9 | 10 | private let requirement = BitRequirement(bitIndex: 12, bitValue: 1) 11 | 12 | func isPresent(in data: Data) -> Bool { 13 | return requirement.check(in: data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataTotalDistanceField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataTotalDistanceField: Field = RowerDataTotalDistanceField() 4 | 5 | private struct RowerDataTotalDistanceField: Field { 6 | 7 | var name = "Total Distance" 8 | var format: Format = .UInt24 9 | 10 | private let requirement = BitRequirement(bitIndex: 2, bitValue: 1) 11 | 12 | func isPresent(in data: Data) -> Bool { 13 | return requirement.check(in: data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/UI/Devices/DevicesView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct DevicesView: View { 4 | 5 | @ObservedObject var viewModel: DevicesViewModel 6 | 7 | var body: some View { 8 | DeviceListView(devices: $viewModel.devices) 9 | .navigationBarTitle("Available devices") 10 | } 11 | } 12 | 13 | struct DevicesView_Previews: PreviewProvider { 14 | static var previews: some View { 15 | DevicesView( 16 | viewModel: DevicesViewModel() 17 | ) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataEnergyPerMinuteField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataEnergyPerMinuteField: Field = RowerDataEnergyPerMinuteField() 4 | 5 | private struct RowerDataEnergyPerMinuteField: Field { 6 | 7 | var name = "Energy Per Minute" 8 | var format: Format = .UInt8 9 | 10 | private let requirement = BitRequirement(bitIndex: 8, bitValue: 1) 11 | 12 | func isPresent(in data: Data) -> Bool { 13 | return requirement.check(in: data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataResistanceLevelField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataResistanceLevelField: Field = RowerDataResistanceLevelField() 4 | 5 | private struct RowerDataResistanceLevelField: Field { 6 | 7 | var name = "Resistance Level" 8 | var format: Format = .SInt16 9 | 10 | private let requirement = BitRequirement(bitIndex: 7, bitValue: 1) 11 | 12 | func isPresent(in data: Data) -> Bool { 13 | return requirement.check(in: data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/GattSpecification/Format.swift: -------------------------------------------------------------------------------- 1 | enum Format { 2 | 3 | case UInt8 4 | case UInt16 5 | case UInt24 6 | case SInt16 7 | } 8 | 9 | extension Format { 10 | 11 | func numberOfBytes() -> Int { 12 | switch self { 13 | case .UInt8: 14 | return 1 15 | case .UInt16: 16 | return 2 17 | case .UInt24: 18 | return 3 19 | case .SInt16: 20 | return 2 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataAverageStrokeRateField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataAverageStrokeRateField: Field = RowerDataAverageStrokeRateField() 4 | 5 | private struct RowerDataAverageStrokeRateField: Field { 6 | 7 | var name = "Average Stroke Rate" 8 | var format: Format = .UInt8 9 | 10 | private let requirement = BitRequirement(bitIndex: 1, bitValue: 1) 11 | 12 | func isPresent(in data: Data) -> Bool { 13 | return requirement.check(in: data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataInstantaneousPaceField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataInstantaneousPaceField: Field = RowerDataInstantaneousPaceField() 4 | 5 | private struct RowerDataInstantaneousPaceField: Field { 6 | 7 | var name = "Instantaneous Pace" 8 | var format: Format = .UInt16 9 | 10 | private let requirement = BitRequirement(bitIndex: 3, bitValue: 1) 11 | 12 | func isPresent(in data: Data) -> Bool { 13 | return requirement.check(in: data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataInstantaneousPowerField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataInstantaneousPowerField: Field = RowerDataInstantaneousPowerField() 4 | 5 | private struct RowerDataInstantaneousPowerField: Field { 6 | 7 | var name = "Instantaneous Power" 8 | var format: Format = .SInt16 9 | 10 | private let requirement = BitRequirement(bitIndex: 5, bitValue: 1) 11 | 12 | func isPresent(in data: Data) -> Bool { 13 | return requirement.check(in: data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/RowerDataSpecification/RowerDataMetabolicEquivalentField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rowerDataMetabolicEquivalentField: Field = RowerDataMetabolicEquivalentField() 4 | 5 | private struct RowerDataMetabolicEquivalentField: Field { 6 | 7 | var name = "Metabolic Equivalent" 8 | var format: Format = .UInt8 9 | 10 | private let requirement = BitRequirement(bitIndex: 10, bitValue: 1) 11 | 12 | func isPresent(in data: Data) -> Bool { 13 | return requirement.check(in: data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /WaterRowerData-BLE.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'WaterRowerData-BLE' 3 | s.version = '0.1.1' 4 | s.summary = 'A library for reading data from a BLE-connected WaterRower device.' 5 | 6 | s.homepage = 'https://github.com/WaterRower-UK/WaterRowerData-iOS' 7 | s.license = { :type => 'Apache 2.0', :file => 'LICENSE' } 8 | s.author = { 'Niek Haarman' => 'niek@305.nl' } 9 | 10 | s.source = { :git => 'https://github.com/WaterRower-UK/WaterRowerData-iOS.git', :tag => s.version.to_s } 11 | s.swift_version = '5.0' 12 | 13 | s.ios.deployment_target = '11.0' 14 | 15 | s.source_files = 'Sources/WaterRowerData-BLE/**/*.swift' 16 | end 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "WaterRowerData-iOS", 7 | platforms: [ 8 | .iOS(.v11) 9 | ], 10 | products: [ 11 | .library( 12 | name: "WaterRowerData-BLE", 13 | targets: ["WaterRowerData-BLE"] 14 | ) 15 | ], 16 | dependencies: [ 17 | ], 18 | targets: [ 19 | .target( 20 | name: "WaterRowerData-BLE", 21 | dependencies: [] 22 | ), 23 | .testTarget( 24 | name: "WaterRowerData-BLETests", 25 | dependencies: ["WaterRowerData-BLE"] 26 | ) 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | excluded: 2 | - Pods 3 | - vendor 4 | 5 | disabled_rules: 6 | - identifier_name # Only allows names with 3-40 characters 7 | - switch_case_alignment # Clashes with .idea formatting 8 | - file_length # Test files are allowed to grow large 9 | - type_body_length # Again, test files. 10 | - function_body_length # And again, test files. 11 | - cyclomatic_complexity # Will be handled during code review 12 | - type_name # Necessary for SwiftUI previews 13 | - nesting # Nesting is perfect for grouping 14 | - todo # We can't implement everything at once 15 | - multiple_closures_with_trailing_closure # This is recommended with SwiftUI -------------------------------------------------------------------------------- /iOS Example/iOS Example/Bluetooth/RowerData/ConnectedRowerDataDevice.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreBluetooth 3 | import WaterRowerData_BLE 4 | 5 | class ConnectedRowerDataDevice { 6 | 7 | private let connectedBleDevice: CBConnectedDevice 8 | 9 | init(from device: CBConnectedDevice) { 10 | self.connectedBleDevice = device 11 | } 12 | 13 | func rowerData(callback: @escaping (RowerData) -> Void) -> Cancellable { 14 | return connectedBleDevice.listen( 15 | serviceUUID: FitnessMachineService.uuid, 16 | characteristicUUID: RowerDataCharacteristic.uuid 17 | ) { data in 18 | callback(RowerDataCharacteristic.decode(data: data)) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | -------------------------------------------------------------------------------- /WaterRowerData-iOS.xcodeproj/WaterRowerData_BLE_Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CFBundleDevelopmentRegion 5 | en 6 | CFBundleExecutable 7 | $(EXECUTABLE_NAME) 8 | CFBundleIdentifier 9 | $(PRODUCT_BUNDLE_IDENTIFIER) 10 | CFBundleInfoDictionaryVersion 11 | 6.0 12 | CFBundleName 13 | $(PRODUCT_NAME) 14 | CFBundlePackageType 15 | FMWK 16 | CFBundleShortVersionString 17 | 1.0 18 | CFBundleSignature 19 | ???? 20 | CFBundleVersion 21 | $(CURRENT_PROJECT_VERSION) 22 | NSPrincipalClass 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /WaterRowerData-iOS.xcodeproj/WaterRowerData_iOS_Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CFBundleDevelopmentRegion 5 | en 6 | CFBundleExecutable 7 | $(EXECUTABLE_NAME) 8 | CFBundleIdentifier 9 | $(PRODUCT_BUNDLE_IDENTIFIER) 10 | CFBundleInfoDictionaryVersion 11 | 6.0 12 | CFBundleName 13 | $(PRODUCT_NAME) 14 | CFBundlePackageType 15 | FMWK 16 | CFBundleShortVersionString 17 | 1.0 18 | CFBundleSignature 19 | ???? 20 | CFBundleVersion 21 | $(CURRENT_PROJECT_VERSION) 22 | NSPrincipalClass 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /WaterRowerData-iOS.xcodeproj/WaterRowerData_BLETests_Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CFBundleDevelopmentRegion 5 | en 6 | CFBundleExecutable 7 | $(EXECUTABLE_NAME) 8 | CFBundleIdentifier 9 | $(PRODUCT_BUNDLE_IDENTIFIER) 10 | CFBundleInfoDictionaryVersion 11 | 6.0 12 | CFBundleName 13 | $(PRODUCT_NAME) 14 | CFBundlePackageType 15 | BNDL 16 | CFBundleShortVersionString 17 | 1.0 18 | CFBundleSignature 19 | ???? 20 | CFBundleVersion 21 | $(CURRENT_PROJECT_VERSION) 22 | NSPrincipalClass 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /WaterRowerData-iOS.xcodeproj/WaterRowerData_iOSTests_Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CFBundleDevelopmentRegion 5 | en 6 | CFBundleExecutable 7 | $(EXECUTABLE_NAME) 8 | CFBundleIdentifier 9 | $(PRODUCT_BUNDLE_IDENTIFIER) 10 | CFBundleInfoDictionaryVersion 11 | 6.0 12 | CFBundleName 13 | $(PRODUCT_NAME) 14 | CFBundlePackageType 15 | BNDL 16 | CFBundleShortVersionString 17 | 1.0 18 | CFBundleSignature 19 | ???? 20 | CFBundleVersion 21 | $(CURRENT_PROJECT_VERSION) 22 | NSPrincipalClass 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/Bluetooth/Connection/CBConnectionFactory.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreBluetooth 3 | 4 | /** 5 | A factory class for creating CBConnection instances. 6 | 7 | This class is not thread safe. 8 | */ 9 | class CBConnectionFactory { 10 | 11 | static let instance = CBConnectionFactory() 12 | 13 | private init() {} 14 | 15 | private var connections = [UUID: CBConnection]() 16 | 17 | func create(from peripheral: CBPeripheral) -> CBConnection { 18 | return create(from: peripheral.identifier) 19 | } 20 | 21 | func create(from identifier: UUID) -> CBConnection { 22 | if let connection = connections[identifier] { 23 | return connection 24 | } 25 | 26 | let connection = CBConnection(identifier: identifier) 27 | connections[identifier] = connection 28 | return connection 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # RELEASING 2 | 3 | This library is available via Swift Package Manager and CocoaPods. 4 | Once you have [configured your setup](#setup), execute the following to 5 | make a release: 6 | 7 | - Update the podspec's `s.version` value in `WaterRowerData-BLE.podspec` 8 | - Commit the changes 9 | - Tag the commit as a release: `git tag x.x.x` 10 | - Push the tags: `git push --tags` 11 | - Execute `pod lib lint` and ensure there are no errors or warnings 12 | - Execute `pod trunk push WaterRowerData-BLE.podspec` 13 | 14 | ## Setup 15 | 16 | You'll need to be authorized to push the library to the CocoaPods trunk. 17 | 18 | - Execute `pod trunk register my@email.com 'My name'` 19 | - Click on the verification link you've received in your mailbox 20 | - Request to be added as a maintainer to the project, see https://guides.cocoapods.org/making/getting-setup-with-trunk#adding-other-people-as-contributors 21 | -------------------------------------------------------------------------------- /Tests/WaterRowerData-BLETests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/Bluetooth/Connection/CBConnectionStateListener.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol CBConnectionStateListener: NSObjectProtocol { 4 | 5 | /** Invoked when the connection state has changed. */ 6 | func onConnectionStateChanged(_ connectionState: CBConnectionState) 7 | } 8 | 9 | /** 10 | A convenience function to construct CBConnectionStateListeners using a closure. 11 | */ 12 | func connectionStateListener(_ closure: @escaping (CBConnectionState) -> Void) -> CBConnectionStateListener { 13 | return ClosureCBBleConnectionStateListener(closure) 14 | } 15 | 16 | private class ClosureCBBleConnectionStateListener: NSObject, CBConnectionStateListener { 17 | 18 | private let closure: (CBConnectionState) -> Void 19 | 20 | init(_ closure: @escaping (CBConnectionState) -> Void) { 21 | self.closure = closure 22 | } 23 | 24 | func onConnectionStateChanged(_ connectionState: CBConnectionState) { 25 | closure(connectionState) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import os 2 | import UIKit 3 | 4 | @UIApplicationMain 5 | class AppDelegate: UIResponder, UIApplicationDelegate { 6 | 7 | func application( 8 | _ application: UIApplication, 9 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 10 | ) -> Bool { 11 | os_log("application didFinishLaunching", type: .debug) 12 | return true 13 | } 14 | 15 | func application( 16 | _ application: UIApplication, 17 | configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions 18 | ) -> UISceneConfiguration { 19 | os_log("application connectingSceneSession", type: .debug) 20 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 21 | } 22 | 23 | func application( 24 | _ application: UIApplication, 25 | didDiscardSceneSessions sceneSessions: Set 26 | ) { 27 | os_log("application didDiscardSceneSessions", type: .debug) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/README.md: -------------------------------------------------------------------------------- 1 | # WaterRowerData-BLE 2 | 3 | This target contains the sources for reading data from a 4 | BLE connected WaterRower device, such as an S5 monitor. 5 | 6 | 7 | The BLE enabled WaterRower modules use the FTMS GATT service 8 | specification with the RowerData GATT characteristic. 9 | This target provides classes that decode raw data received 10 | from a GATT characteristic into useable rower data. 11 | 12 | ## Usage 13 | 14 | Once connected to a WaterRower monitor and receiving data 15 | (see ['Receiving data'](#receiving-data)), you'll receive the data as a [`Data` 16 | byte buffer](https://developer.apple.com/documentation/foundation/data). 17 | You can pass this `Data` instance into the static 18 | `RowerDataCharacteristic.decode(data: Data)` function to decode the bytes 19 | into a `RowerData` struct: 20 | 21 | ```swift 22 | import WaterRowerData_BLE 23 | 24 | /* ... */ 25 | 26 | func peripheral( 27 | _ peripheral: CBPeripheral, 28 | didUpdateValueFor characteristic: CBCharacteristic, 29 | error: Error? 30 | ) { 31 | guard let data = characteristic.value else { 32 | return 33 | } 34 | 35 | let rowerData = RowerDataCharacteristic.decode(data) 36 | print(rowerData) 37 | } 38 | ``` 39 | 40 | This `RowerData` struct will contain the rower data that 41 | was encoded in the `Data` byte buffer. 42 | 43 | > :warning:   **Note**: A single `Data` byte buffer instance does not always 44 | contain _all_ data values. 45 | Due to restrictions in buffer size some of the `RowerData` properties will be absent, 46 | which is represented as a `nil` value. 47 | 48 | 49 | ## Receiving data 50 | 51 | TODO 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WaterRowerData for iOS 2 | 3 | A swift package for reading data from a BLE-connected WaterRower device. 4 | 5 | ## WaterRowerData-BLE 6 | 7 | The WaterRowerData-BLE target contains the sources for reading data from 8 | a BLE connected WaterRower device, such as an S5 monitor. 9 | See [WaterRowerData-BLE/Readme](Sources/WaterRowerData-BLE/README.md) for 10 | usage instructions. 11 | 12 | ## Setup 13 | 14 | To include the package in your project, follow one of the following methods: 15 | 16 | ### XCode 17 | 18 | See [Adding Package Dependencies to Your App](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app): 19 | 20 | - Select File > Swift Packages > Add Package Dependency 21 | - Enter `https://github.com/WaterRowerData-UK/WaterRowerData-iOS` 22 | - Select the version, or leave as is 23 | - Choose package products and targets 24 | 25 | ### XCodeGen 26 | 27 | In your `project.yml` file include the following, replacing `x.x.x` with the 28 | latest release version: 29 | 30 | ```diff 31 | + packages: 32 | + SwiftPM: 33 | + url: https://github.com/WaterRowerData-UK/WaterRowerData-iOS 34 | + version: x.x.x 35 | targets: 36 | App 37 | + dependencies: 38 | + - package: SwiftPM 39 | + - product: WaterRowerData-BLE 40 | ``` 41 | 42 | Run `xcodegen generate`. 43 | 44 | ## Development 45 | 46 | Swift Package Manager is used to test and build this library: 47 | 48 | - `swift build` builds the sources into binaries 49 | - `swift test` builds and runs the tests 50 | 51 | Open `WaterRowerData-iOS.xcodeproj` to start editing the source files. 52 | Open `iOS Example/iOS Example.xcworkspace` to view and run the sample 53 | application. 54 | 55 | ## Releasing 56 | 57 | See [RELEASING.md](RELEASING.md) 58 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/Internal/GattSpecification/Data+ReadFormat.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Data { 4 | 5 | func readIntValue(format: Format, offset: Int) -> Int { 6 | switch format { 7 | case .UInt8: 8 | return unsignedByteToInt(self[offset]) 9 | case .UInt16: 10 | return unsignedBytesToInt(self[offset], self[offset + 1]) 11 | case .UInt24: 12 | return unsignedBytesToInt(self[offset], self[offset + 1], self[offset + 2]) 13 | case .SInt16: 14 | return unsignedToSigned(unsignedBytesToInt(self[offset], self[offset + 1]), size: 16) 15 | } 16 | } 17 | 18 | private func unsignedByteToInt(_ b: UInt8) -> Int { 19 | return Int(b) & 0xFF 20 | } 21 | 22 | private func unsignedBytesToInt(_ b0: UInt8, _ b1: UInt8) -> Int { 23 | return unsignedByteToInt(b0) + (unsignedByteToInt(b1) << 8) 24 | } 25 | 26 | private func unsignedBytesToInt(_ b0: UInt8, _ b1: UInt8, _ b2: UInt8) -> Int { 27 | return unsignedByteToInt(b0) + (unsignedByteToInt(b1) << 8) + (unsignedByteToInt(b2) << 16) 28 | } 29 | 30 | private func unsignedBytesToInt(_ b0: UInt8, _ b1: UInt8, _ b2: UInt8, _ b3: UInt8) -> Int { 31 | return unsignedByteToInt(b0) + 32 | (unsignedByteToInt(b1) << 8) + 33 | (unsignedByteToInt(b2) << 16) + 34 | (unsignedByteToInt(b3) << 24) 35 | } 36 | 37 | /** 38 | Convert an unsigned integer value to a two's-complement encoded 39 | signed value. 40 | */ 41 | private func unsignedToSigned(_ unsigned: Int, size: Int) -> Int { 42 | if unsigned & (1 << (size - 1)) == 0 { 43 | return unsigned 44 | } 45 | 46 | let a = 1 << size - 1 47 | return -1 * (a - (unsigned & (a - 1))) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "idiom": "iphone", 5 | "scale": "2x", 6 | "size": "20x20" 7 | }, 8 | { 9 | "idiom": "iphone", 10 | "scale": "3x", 11 | "size": "20x20" 12 | }, 13 | { 14 | "idiom": "iphone", 15 | "scale": "2x", 16 | "size": "29x29" 17 | }, 18 | { 19 | "idiom": "iphone", 20 | "scale": "3x", 21 | "size": "29x29" 22 | }, 23 | { 24 | "idiom": "iphone", 25 | "scale": "2x", 26 | "size": "40x40" 27 | }, 28 | { 29 | "idiom": "iphone", 30 | "scale": "3x", 31 | "size": "40x40" 32 | }, 33 | { 34 | "idiom": "iphone", 35 | "scale": "2x", 36 | "size": "60x60" 37 | }, 38 | { 39 | "idiom": "iphone", 40 | "scale": "3x", 41 | "size": "60x60" 42 | }, 43 | { 44 | "idiom": "ipad", 45 | "scale": "1x", 46 | "size": "20x20" 47 | }, 48 | { 49 | "idiom": "ipad", 50 | "scale": "2x", 51 | "size": "20x20" 52 | }, 53 | { 54 | "idiom": "ipad", 55 | "scale": "1x", 56 | "size": "29x29" 57 | }, 58 | { 59 | "idiom": "ipad", 60 | "scale": "2x", 61 | "size": "29x29" 62 | }, 63 | { 64 | "idiom": "ipad", 65 | "scale": "1x", 66 | "size": "40x40" 67 | }, 68 | { 69 | "idiom": "ipad", 70 | "scale": "2x", 71 | "size": "40x40" 72 | }, 73 | { 74 | "idiom": "ipad", 75 | "scale": "1x", 76 | "size": "76x76" 77 | }, 78 | { 79 | "idiom": "ipad", 80 | "scale": "2x", 81 | "size": "76x76" 82 | }, 83 | { 84 | "idiom": "ipad", 85 | "scale": "2x", 86 | "size": "83.5x83.5" 87 | }, 88 | { 89 | "idiom": "ios-marketing", 90 | "scale": "1x", 91 | "size": "1024x1024" 92 | } 93 | ], 94 | "info": { 95 | "author": "xcode", 96 | "version": 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/UI/DeviceDetails/DeviceDetailsViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import WaterRowerData_BLE 4 | 5 | class DeviceDetailsViewModel: ObservableObject { 6 | 7 | private let device: Device 8 | private let connection: CBConnection 9 | 10 | var deviceName: String { 11 | return device.name 12 | } 13 | 14 | @Published var connectionStatus: ConnectionStatus = .disconnected 15 | @Published var rowerData: RowerData? 16 | 17 | init(_ device: Device) { 18 | self.device = device 19 | self.connection = CBConnectionFactory.instance.create(from: device.id) 20 | } 21 | 22 | private var connectionStatusCancellable: Cancellable? 23 | 24 | func viewDidAppear() { 25 | connectionStatusCancellable = connection.addConnectionStateListener(connectionStateListener { state in 26 | self.handle(connectionUpdate: state) 27 | }) 28 | } 29 | 30 | private var rowerDataCancellable: Cancellable? 31 | 32 | private func handle(connectionUpdate state: CBConnectionState) { 33 | switch state { 34 | case .disconnected: 35 | self.connectionStatus = .disconnected 36 | case .connecting: 37 | self.connectionStatus = .connecting 38 | case .connected: 39 | self.connectionStatus = .connected 40 | case .failed: 41 | self.connectionStatus = .failed 42 | } 43 | 44 | if case .connected(device: let device) = state { 45 | rowerDataCancellable = ConnectedRowerDataDevice(from: device).rowerData { rowerData in 46 | self.rowerData = rowerData 47 | } 48 | } else { 49 | rowerDataCancellable = nil 50 | rowerData = nil 51 | } 52 | } 53 | 54 | func viewDidDisappear() { 55 | connectionStatusCancellable = nil 56 | rowerDataCancellable = nil 57 | disconnect() 58 | } 59 | 60 | func connect() { 61 | connection.connect() 62 | } 63 | 64 | func disconnect() { 65 | connection.disconnect() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import os 2 | import CoreBluetooth 3 | import UIKit 4 | import SwiftUI 5 | import WaterRowerData_BLE 6 | 7 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 8 | 9 | var window: UIWindow? 10 | 11 | private let scanner = CBScanner() 12 | private var cancellable: Cancellable? 13 | 14 | func scene( 15 | _ scene: UIScene, 16 | willConnectTo session: UISceneSession, 17 | options connectionOptions: UIScene.ConnectionOptions 18 | ) { 19 | os_log("scene willConnectToSession", type: .debug) 20 | 21 | let devicesViewModel = DevicesViewModel() 22 | let devicesView = DevicesView(viewModel: devicesViewModel) 23 | 24 | cancellable = scanner.startScan( 25 | // withServices: [CBUUID(nsuuid: FitnessMachineService.uuid)] 26 | withServices: nil 27 | ) { result in 28 | if let name = result.peripheral.name { 29 | devicesViewModel.append( 30 | Device( 31 | id: result.peripheral.identifier, 32 | name: name 33 | ) 34 | ) 35 | } 36 | } 37 | 38 | let rootView = NavigationView { devicesView } 39 | 40 | if let windowScene = scene as? UIWindowScene { 41 | let window = UIWindow(windowScene: windowScene) 42 | window.rootViewController = UIHostingController(rootView: rootView) 43 | self.window = window 44 | window.makeKeyAndVisible() 45 | } 46 | } 47 | 48 | func sceneDidDisconnect(_ scene: UIScene) { 49 | os_log("sceneDidDisconnect", type: .debug) 50 | } 51 | 52 | func sceneDidBecomeActive(_ scene: UIScene) { 53 | os_log("sceneDidBecomeActive", type: .debug) 54 | } 55 | 56 | func sceneWillResignActive(_ scene: UIScene) { 57 | os_log("sceneWillResignActive", type: .debug) 58 | } 59 | 60 | func sceneWillEnterForeground(_ scene: UIScene) { 61 | os_log("sceneWillEnterForeground", type: .debug) 62 | } 63 | 64 | func sceneDidEnterBackground(_ scene: UIScene) { 65 | os_log("sceneDidEnterBackground", type: .debug) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Tests/WaterRowerData-BLETests/CharacteristicData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | class CharacteristicData { 5 | 6 | static func create( 7 | flags: Data, 8 | values: UInt8... 9 | ) -> Data { 10 | var result = Data(count: flags.count + values.count) 11 | var i = 0 12 | 13 | flags.forEach { (value) in 14 | result[i] = value 15 | i += 1 16 | } 17 | 18 | values.forEach { (value) in 19 | result[i] = value 20 | i += 1 21 | } 22 | 23 | return result 24 | } 25 | } 26 | 27 | class CharacteristicDataTest: XCTestCase { 28 | 29 | func test_create_without_flags_or_values() { 30 | /* Given */ 31 | let flags = CharacteristicFlags.createFlags(flags: [:]) 32 | 33 | /* When */ 34 | let result = CharacteristicData.create(flags: flags) 35 | 36 | /* Then */ 37 | XCTAssertEqual(result.count, 2) 38 | XCTAssertEqual(result[0], UInt8("00000000", radix: 2)) 39 | XCTAssertEqual(result[1], UInt8("00000000", radix: 2)) 40 | } 41 | 42 | func test_create_with_flags_without_values() { 43 | /* Given */ 44 | let flags = CharacteristicFlags.createFlags(flags: [1: true]) 45 | 46 | /* When */ 47 | let result = CharacteristicData.create(flags: flags) 48 | 49 | /* Then */ 50 | XCTAssertEqual(result.count, 2) 51 | XCTAssertEqual(result[0], UInt8("00000010", radix: 2)) 52 | XCTAssertEqual(result[1], UInt8("00000000", radix: 2)) 53 | } 54 | 55 | func test_create_with_flags_with_single_value() { 56 | /* Given */ 57 | let flags = CharacteristicFlags.createFlags(flags: [1: true]) 58 | 59 | /* When */ 60 | let result = CharacteristicData.create(flags: flags, values: 3) 61 | 62 | /* Then */ 63 | XCTAssertEqual(result.count, 3) 64 | XCTAssertEqual(result[0], UInt8("00000010", radix: 2)) 65 | XCTAssertEqual(result[1], UInt8("00000000", radix: 2)) 66 | XCTAssertEqual(result[2], 3) 67 | } 68 | 69 | func test_create_with_flags_with_multiple_values() { 70 | /* Given */ 71 | let flags = CharacteristicFlags.createFlags(flags: [1: true]) 72 | 73 | /* When */ 74 | let result = CharacteristicData.create(flags: flags, values: 3, 5) 75 | 76 | /* Then */ 77 | XCTAssertEqual(result.count, 4) 78 | XCTAssertEqual(result[0], UInt8("00000010", radix: 2)) 79 | XCTAssertEqual(result[1], UInt8("00000000", radix: 2)) 80 | XCTAssertEqual(result[2], 3) 81 | XCTAssertEqual(result[3], 5) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/RowerData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /** 4 | A struct that contains decoded Rower Data. 5 | */ 6 | public struct RowerData { 7 | 8 | /** 9 | The instantaneous stroke rate in strokes/minute. 10 | */ 11 | public let strokeRate: Double? 12 | 13 | /** 14 | The total number of strokes since the beginning of the training session. 15 | */ 16 | public let strokeCount: Int? 17 | 18 | /** 19 | The average stroke rate since the beginning of the training session, in 20 | strokes/minute. 21 | */ 22 | public let averageStrokeRate: Double? 23 | 24 | /** 25 | The total distance since the beginning of the training session, in meters. 26 | */ 27 | public let totalDistanceMeters: Int? 28 | 29 | /** 30 | The value of the pace (time per 500 meters) of the user while exercising, 31 | in seconds. 32 | */ 33 | public let instantaneousPaceSeconds: Int? 34 | 35 | /** 36 | The value of the average pace (time per 500 meters) since the beginning of 37 | the training session, in seconds. 38 | */ 39 | public let averagePaceSeconds: Int? 40 | 41 | /** 42 | The value of the instantaneous power in Watts. 43 | */ 44 | public let instantaneousPowerWatts: Int? 45 | 46 | /** 47 | The value of the average power since the beginning of the training session, 48 | in Watts. 49 | */ 50 | public let averagePowerWatts: Int? 51 | 52 | /** 53 | The current value of the resistance level. 54 | */ 55 | public let resistanceLevel: Int? 56 | 57 | /** 58 | The total expended energy of a user since the training session has started, 59 | in Kilocalories. 60 | */ 61 | public let totalEnergyKiloCalories: Int? 62 | 63 | /** 64 | The average expended energy of a user during a period of one hour, in 65 | Kilocalories. 66 | */ 67 | public let energyPerHourKiloCalories: Int? 68 | 69 | /** 70 | The average expended energy of a user during a period of one minute, in 71 | Kilocalories. 72 | */ 73 | public let energyPerMinuteKiloCalories: Int? 74 | 75 | /** 76 | The current heart rate value of the user, in beats per minute. 77 | */ 78 | public let heartRate: Int? 79 | 80 | /** 81 | The metabolic equivalent of the user. 82 | */ 83 | public let metabolicEquivalent: Double? 84 | 85 | /** 86 | The elapsed time of a training session since the training session has 87 | started, in seconds. 88 | */ 89 | public let elapsedTimeSeconds: Int? 90 | 91 | /** 92 | The remaining time of a selected training session, in seconds. 93 | */ 94 | public let remainingTimeSeconds: Int? 95 | } 96 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSBluetoothAlwaysUsageDescription 6 | Connect to FTMS devices 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | UIApplicationSceneManifest 26 | 27 | UIApplicationSupportsMultipleScenes 28 | 29 | UISceneConfigurations 30 | 31 | UIWindowSceneSessionRoleApplication 32 | 33 | 34 | UISceneConfigurationName 35 | Default Configuration 36 | UISceneDelegateClassName 37 | $(PRODUCT_MODULE_NAME).SceneDelegate 38 | 39 | 40 | 41 | 42 | UILaunchStoryboardName 43 | LaunchScreen 44 | UIRequiredDeviceCapabilities 45 | 46 | armv7 47 | 48 | UISupportedInterfaceOrientations 49 | 50 | UIInterfaceOrientationPortrait 51 | UIInterfaceOrientationLandscapeLeft 52 | UIInterfaceOrientationLandscapeRight 53 | 54 | UISupportedInterfaceOrientations~ipad 55 | 56 | UIInterfaceOrientationPortrait 57 | UIInterfaceOrientationPortraitUpsideDown 58 | UIInterfaceOrientationLandscapeLeft 59 | UIInterfaceOrientationLandscapeRight 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /WaterRowerData-iOS.xcodeproj/xcshareddata/xcschemes/WaterRowerData-iOS-Package.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 54 | 60 | 61 | 63 | 64 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /Tests/WaterRowerData-BLETests/CharacteristicFlags.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | /** 5 | A helper class for tests to construct flag bytes. 6 | */ 7 | class CharacteristicFlags { 8 | 9 | /** 10 | Creates a 16 bit data buffer, by putting a `1` on each indicated index: 11 | 12 | [0: true] becomes 0000 0000 0000 0001 13 | [7: true] becomes 0000 0000 1000 0000 14 | */ 15 | static func createFlags( 16 | flags: [Int: Bool] 17 | ) -> Data { 18 | var result = Data(count: 2) 19 | 20 | let flagsValue = flags.enumerated() 21 | .reduce(UInt16(0)) { (accumulation, item) -> UInt16 in 22 | if item.element.value { 23 | return accumulation + UInt16(1 << item.element.key) 24 | } 25 | 26 | return accumulation 27 | } 28 | 29 | result[0] = UInt8(clamping: flagsValue & 0xFF) 30 | result[1] = UInt8(clamping: (flagsValue >> 8) & 0xFF) 31 | 32 | return result 33 | } 34 | } 35 | 36 | class CharacteristicFlagsTest: XCTestCase { 37 | 38 | func test_no_active_flags() { 39 | /* When */ 40 | let result = CharacteristicFlags.createFlags(flags: [:]) 41 | 42 | /* Then */ 43 | XCTAssertEqual(result[0], UInt8("00000000", radix: 2)) 44 | XCTAssertEqual(result[1], UInt8("00000000", radix: 2)) 45 | } 46 | 47 | func test_active_flag_on_0th_index() { 48 | /* When */ 49 | let result = CharacteristicFlags.createFlags(flags: [0: true]) 50 | 51 | /* Then */ 52 | XCTAssertEqual(result[0], UInt8("00000001", radix: 2)) 53 | XCTAssertEqual(result[1], UInt8("00000000", radix: 2)) 54 | } 55 | 56 | func test_active_flag_on_7th_index() { 57 | /* When */ 58 | let result = CharacteristicFlags.createFlags(flags: [7: true]) 59 | 60 | /* Then */ 61 | XCTAssertEqual(result[0], UInt8("10000000", radix: 2)) 62 | XCTAssertEqual(result[1], UInt8("00000000", radix: 2)) 63 | } 64 | 65 | func test_active_flag_on_8th_index() { 66 | /* When */ 67 | let result = CharacteristicFlags.createFlags(flags: [8: true]) 68 | 69 | /* Then */ 70 | XCTAssertEqual(result[0], UInt8("00000000", radix: 2)) 71 | XCTAssertEqual(result[1], UInt8("00000001", radix: 2)) 72 | } 73 | 74 | func test_active_flag_on_15th_index() { 75 | /* When */ 76 | let result = CharacteristicFlags.createFlags(flags: [15: true]) 77 | 78 | /* Then */ 79 | XCTAssertEqual(result[0], UInt8("00000000", radix: 2)) 80 | XCTAssertEqual(result[1], UInt8("10000000", radix: 2)) 81 | } 82 | 83 | func test_all_active_flags() { 84 | /* Given */ 85 | let flags = [ 86 | 0: true, 87 | 1: true, 88 | 2: true, 89 | 3: true, 90 | 4: true, 91 | 5: true, 92 | 6: true, 93 | 7: true, 94 | 8: true, 95 | 9: true, 96 | 10: true, 97 | 11: true, 98 | 12: true, 99 | 13: true, 100 | 14: true, 101 | 15: true 102 | ] 103 | 104 | /* When */ 105 | let result = CharacteristicFlags.createFlags(flags: flags) 106 | 107 | /* Then */ 108 | XCTAssertEqual(result[0], UInt8("11111111", radix: 2)) 109 | XCTAssertEqual(result[1], UInt8("11111111", radix: 2)) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/UI/DeviceDetails/DeviceDetailsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import WaterRowerData_BLE 3 | 4 | struct DeviceDetailsView: View { 5 | 6 | @ObservedObject var viewModel: DeviceDetailsViewModel 7 | 8 | var body: some View { 9 | List { 10 | connectionRow() 11 | 12 | if viewModel.connectionStatus == .connected { 13 | Group { 14 | dataRow(title: "Stroke rate") { rowerData in rowerData?.strokeRate } 15 | dataRow(title: "Stroke count") { rowerData in rowerData?.strokeCount } 16 | dataRow(title: "Average stroke rate") { rowerData in rowerData?.averageStrokeRate } 17 | dataRow(title: "Total distance") { rowerData in rowerData?.totalDistanceMeters } 18 | dataRow(title: "Instantaneous pace") { rowerData in rowerData?.instantaneousPaceSeconds } 19 | dataRow(title: "Average pace") { rowerData in rowerData?.averagePaceSeconds } 20 | dataRow(title: "Instantaneous power") { rowerData in rowerData?.instantaneousPowerWatts } 21 | dataRow(title: "Average power") { rowerData in rowerData?.averagePowerWatts } 22 | dataRow(title: "Resistance level") { rowerData in rowerData?.resistanceLevel } 23 | dataRow(title: "Total energy") { rowerData in rowerData?.totalEnergyKiloCalories } 24 | } 25 | 26 | Group { 27 | dataRow(title: "Energy per hour") { rowerData in rowerData?.energyPerHourKiloCalories } 28 | dataRow(title: "Energy per minute") { rowerData in rowerData?.energyPerMinuteKiloCalories } 29 | dataRow(title: "Heart rate") { rowerData in rowerData?.heartRate } 30 | dataRow(title: "Metabolic equivalent") { rowerData in rowerData?.metabolicEquivalent } 31 | dataRow(title: "Elapsed time") { rowerData in rowerData?.elapsedTimeSeconds } 32 | dataRow(title: "Remaining time") { rowerData in rowerData?.remainingTimeSeconds } 33 | } 34 | } 35 | }.navigationBarTitle(viewModel.deviceName) 36 | .onAppear { self.viewModel.viewDidAppear() } 37 | .onDisappear { self.viewModel.viewDidDisappear() } 38 | } 39 | 40 | private func connectionRow() -> some View { 41 | HStack { 42 | connectionStatusText() 43 | Spacer() 44 | connectionButton().buttonStyle(BorderlessButtonStyle()) 45 | } 46 | } 47 | 48 | private func connectionStatusText() -> Text { 49 | return Text(viewModel.connectionStatus.rawValue) 50 | } 51 | 52 | private func connectionButton() -> some View { 53 | switch viewModel.connectionStatus { 54 | case .disconnected, .connecting, .failed: 55 | return AnyView(connectButton(connectionStatus: viewModel.connectionStatus)) 56 | case .connected: 57 | return AnyView(disconnectButton()) 58 | } 59 | } 60 | 61 | private func connectButton(connectionStatus: ConnectionStatus) -> some View { 62 | return Button(action: { 63 | self.viewModel.connect() 64 | }) { 65 | Text("Connect") 66 | }.disabled(connectionStatus != .disconnected) 67 | } 68 | 69 | private func disconnectButton() -> some View { 70 | return Button(action: { 71 | self.viewModel.disconnect() 72 | }) { 73 | Text("Disconnect") 74 | }.disabled(false) 75 | } 76 | 77 | private func dataRow( 78 | title: String, 79 | value: (RowerData?) -> Any? 80 | ) -> some View { 81 | return HStack { 82 | Text(title) 83 | Spacer() 84 | valueText(value: value(viewModel.rowerData)) 85 | } 86 | } 87 | 88 | private func valueText(value: Any?) -> Text { 89 | if let value = value { 90 | return Text(String(describing: value)) 91 | } else { 92 | return Text("-") 93 | } 94 | } 95 | } 96 | 97 | struct DeviceDetailsView_Previews: PreviewProvider { 98 | static var previews: some View { 99 | DeviceDetailsView( 100 | viewModel: DeviceDetailsViewModel( 101 | Device(id: UUID(), name: "My device") 102 | ) 103 | ) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/Bluetooth/Connection/CBConnectedDevice.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreBluetooth 3 | import os 4 | 5 | private let log = OSLog(subsystem: "uk.co.waterrower.bluetooth.plist", category: "CBConnectedBleDevice") 6 | 7 | /** 8 | Represents a connected BLE device. 9 | 10 | Use `CBConnection` to obtain an instance of this class. 11 | */ 12 | class CBConnectedDevice: NSObject, CBPeripheralDelegate { 13 | 14 | private let peripheral: CBPeripheral 15 | 16 | /** 17 | - Parameter from: A `CBPeripheral` that is connected. 18 | */ 19 | init( 20 | from peripheral: CBPeripheral 21 | ) { 22 | self.peripheral = peripheral 23 | super.init() 24 | peripheral.delegate = self 25 | } 26 | 27 | private var callbacks: [CBUUID: [ListenCallback]] = [:] 28 | 29 | /** 30 | Enables characteristic notifications for given `serviceUUID` and 31 | `characteristicUUID` and invokes `callback` when notifications arrive. 32 | 33 | For simplicity for this sample app, this function does not disable the 34 | characteristic notifications when invoking the resulting cancellable. 35 | 36 | - Returns: A `Cancellable` that can be used to stop receiving notifications. 37 | A strong reference must be held to this instance, 38 | disposing of the reference cancels the listener. 39 | */ 40 | func listen( 41 | serviceUUID: UUID, 42 | characteristicUUID: UUID, 43 | callback: @escaping (Data) -> Void 44 | ) -> Cancellable { 45 | guard let services: [CBService] = peripheral.services else { 46 | fatalError("Peripheral has no services") 47 | } 48 | 49 | guard let service = services.first(where: { service in 50 | service.uuid == CBUUID(nsuuid: serviceUUID) 51 | }) else { 52 | fatalError("Service doesn't exist for UUID: \(serviceUUID)") 53 | } 54 | 55 | guard let characteristic = service.characteristics?.first(where: { characteristic in 56 | characteristic.uuid == CBUUID(nsuuid: characteristicUUID) 57 | }) else { 58 | fatalError("Characteristic doesn't exist for UUID: \(characteristicUUID)") 59 | } 60 | 61 | let characteristicCBUUID = CBUUID(nsuuid: characteristicUUID) 62 | 63 | var callbackList: [ListenCallback]? = callbacks[characteristicCBUUID] 64 | if callbackList == nil { 65 | callbackList = [] 66 | } 67 | let callback = ListenCallback(callback) 68 | callbackList!.append(callback) 69 | callbacks[characteristicCBUUID] = callbackList 70 | 71 | os_log("Set notify value true for %@", log: log, type: .debug, characteristic) 72 | peripheral.setNotifyValue(true, for: characteristic) 73 | peripheral.writeValue( 74 | Data(), 75 | for: characteristic, 76 | type: CBCharacteristicWriteType.withResponse 77 | ) 78 | 79 | return CancelListening( 80 | self, 81 | characteristicCBUUID, 82 | callback 83 | ) 84 | } 85 | 86 | private class ListenCallback { 87 | 88 | let closure: (Data) -> Void 89 | 90 | init(_ closure: @escaping (Data) -> Void) { 91 | self.closure = closure 92 | } 93 | } 94 | 95 | private class CancelListening: Cancellable { 96 | 97 | private weak var owner: CBConnectedDevice? 98 | private let uuid: CBUUID 99 | private let callback: ListenCallback 100 | 101 | init( 102 | _ owner: CBConnectedDevice, 103 | _ uuid: CBUUID, 104 | _ callback: ListenCallback 105 | ) { 106 | self.owner = owner 107 | self.uuid = uuid 108 | self.callback = callback 109 | } 110 | 111 | func cancel() { 112 | os_log("Removing callback for %@", log: log, type: .debug, uuid) 113 | owner?.callbacks[uuid]?.removeAll(where: { c -> Bool in 114 | c === callback 115 | }) 116 | } 117 | 118 | deinit { 119 | cancel() 120 | } 121 | } 122 | 123 | func peripheral( 124 | _ peripheral: CBPeripheral, 125 | didUpdateValueFor characteristic: CBCharacteristic, 126 | error: Error? 127 | ) { 128 | callbacks[characteristic.uuid]?.forEach { callback in 129 | if let data = characteristic.value { 130 | callback.closure(data) 131 | } 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /iOS Example/iOS Example/Bluetooth/Discovery/CBScanner.swift: -------------------------------------------------------------------------------- 1 | import os 2 | import CoreBluetooth 3 | import Foundation 4 | 5 | private let log = OSLog(subsystem: "uk.co.waterrower.bluetooth.plist", category: "CBBleScanner") 6 | 7 | /** 8 | A class that helps with scanning for BLE devices. 9 | 10 | This class instantiates a new `CBCentralManager` for each scan request, 11 | allowing the implementation to keep the scan results separate. 12 | */ 13 | class CBScanner { 14 | 15 | /** Strong references to the active managers */ 16 | private var managers: [CBCentralManager] = [] 17 | 18 | /** Strong references to the active delegates */ 19 | private var delegates: [CBCentralManagerDelegate] = [] 20 | 21 | /** 22 | Starts a scan for peripherals that are advertising. 23 | 24 | - Parameter withServices: An array of CBUUID objects that the app is 25 | interested in. Each CBUUID object represents the UUID of a service 26 | that a peripheral advertises. 27 | 28 | - Parameter allowDuplicates: If `true`, filtering is disabled and a 29 | discovery event is generated each time an advertising packet is 30 | received from the peripheral. If `false`, multiple discoveries of 31 | the same peripheral are grouped in a single event. 32 | 33 | - Returns: A `Cancellable` that can be used to cancel the scan. 34 | A strong reference must be held to this instance, disposing of 35 | the reference cancels the scan. 36 | */ 37 | func startScan( 38 | withServices scanServicesUUIDs: [CBUUID]?, 39 | allowDuplicates: Bool = false, 40 | _ callback: @escaping (CBScanResult) -> Void 41 | ) -> Cancellable { 42 | let delegate = BLEScanDelegate(withServices: scanServicesUUIDs, allowDuplicates: allowDuplicates, callback) 43 | let manager = CBCentralManager(delegate: delegate, queue: nil) 44 | 45 | managers.append(manager) 46 | delegates.append(delegate) 47 | 48 | return BLEScanCanceller(self, manager, delegate) 49 | } 50 | 51 | private class BLEScanDelegate: NSObject, CBCentralManagerDelegate { 52 | 53 | private let scanServiceUUIDs: [CBUUID]? 54 | private let allowDuplicates: Bool 55 | private let callback: (CBScanResult) -> Void 56 | 57 | init( 58 | withServices scanServiceUUIDs: [CBUUID]?, 59 | allowDuplicates: Bool, 60 | _ callback: @escaping (CBScanResult) -> Void 61 | ) { 62 | self.scanServiceUUIDs = scanServiceUUIDs 63 | self.allowDuplicates = allowDuplicates 64 | self.callback = callback 65 | } 66 | 67 | func centralManagerDidUpdateState(_ central: CBCentralManager) { 68 | os_log("centralManagerDidUpdateState %d", log: log, type: .debug, central.state.rawValue) 69 | 70 | guard central.state == .poweredOn else { 71 | os_log("Invalid state, not scanning: %d", log: log, type: .error, central.state.rawValue) 72 | return 73 | } 74 | 75 | if scanServiceUUIDs == nil { 76 | os_log("Start scanning for peripherals without filters", log: log, type: .debug) 77 | } else { 78 | os_log( 79 | "Start scanning for peripherals: %@", 80 | log: log, 81 | type: .debug, 82 | scanServiceUUIDs! 83 | ) 84 | } 85 | 86 | let options = [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates] 87 | central.scanForPeripherals( 88 | withServices: scanServiceUUIDs, 89 | options: options 90 | ) 91 | } 92 | 93 | func centralManager( 94 | _ central: CBCentralManager, 95 | didDiscover peripheral: CBPeripheral, 96 | advertisementData: [String: Any], 97 | rssi RSSI: NSNumber 98 | ) { 99 | // os_log("centralManagerDidDiscover %@", log: log, type: .debug, "\(peripheral)") 100 | 101 | callback( 102 | CBScanResult( 103 | peripheral: peripheral, 104 | advertisementData: advertisementData, 105 | rssi: RSSI 106 | ) 107 | ) 108 | } 109 | } 110 | 111 | private class BLEScanCanceller: Cancellable { 112 | 113 | private unowned let scanner: CBScanner 114 | 115 | // swiftlint:disable weak_delegate 116 | private let delegate: CBCentralManagerDelegate 117 | private let manager: CBCentralManager 118 | 119 | init( 120 | _ scanner: CBScanner, 121 | _ manager: CBCentralManager, 122 | _ delegate: CBCentralManagerDelegate 123 | 124 | ) { 125 | self.scanner = scanner 126 | self.manager = manager 127 | self.delegate = delegate 128 | } 129 | 130 | func cancel() { 131 | os_log("Cancelling scan", log: log, type: .debug) 132 | scanner.managers.removeAll { m in m === manager } 133 | scanner.delegates.removeAll { d in d === delegate } 134 | 135 | if manager.state == .poweredOn { 136 | manager.stopScan() 137 | } 138 | } 139 | 140 | deinit { 141 | cancel() 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /Tests/WaterRowerData-BLETests/RowerDataCharacteristicFlags.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | class RowerDataCharacteristicFlags { 5 | 6 | static func create( 7 | moreDataPresent: Bool = false, 8 | averageStrokeRatePresent: Bool = false, 9 | totalDistancePresent: Bool = false, 10 | instantaneousPacePresent: Bool = false, 11 | averagePacePresent: Bool = false, 12 | instantaneousPowerPresent: Bool = false, 13 | averagePowerPresent: Bool = false, 14 | resistanceLevelPresent: Bool = false, 15 | expendedEnergyPresent: Bool = false, 16 | heartRatePresent: Bool = false, 17 | metabolicEquivalentPresent: Bool = false, 18 | elapsedTimePresent: Bool = false, 19 | remainingTimePresent: Bool = false 20 | ) -> Data { 21 | var flags: [Int: Bool] = [:] 22 | 23 | if !moreDataPresent { 24 | flags[0] = true 25 | } 26 | 27 | if averageStrokeRatePresent { 28 | flags[1] = true 29 | } 30 | 31 | if totalDistancePresent { 32 | flags[2] = true 33 | } 34 | 35 | if instantaneousPacePresent { 36 | flags[3] = true 37 | } 38 | 39 | if averagePacePresent { 40 | flags[4] = true 41 | } 42 | 43 | if instantaneousPowerPresent { 44 | flags[5] = true 45 | } 46 | 47 | if averagePowerPresent { 48 | flags[6] = true 49 | } 50 | 51 | if resistanceLevelPresent { 52 | flags[7] = true 53 | } 54 | 55 | if expendedEnergyPresent { 56 | flags[8] = true 57 | } 58 | 59 | if heartRatePresent { 60 | flags[9] = true 61 | } 62 | 63 | if metabolicEquivalentPresent { 64 | flags[10] = true 65 | } 66 | 67 | if elapsedTimePresent { 68 | flags[11] = true 69 | } 70 | 71 | if remainingTimePresent { 72 | flags[12] = true 73 | } 74 | 75 | return CharacteristicFlags.createFlags(flags: flags) 76 | } 77 | } 78 | 79 | class RowerDataCharacteristicFlagsTest: XCTestCase { 80 | 81 | func test_moreData_present() { 82 | /* When */ 83 | let result = RowerDataCharacteristicFlags.create( 84 | moreDataPresent: true 85 | ) 86 | 87 | /* Then */ 88 | XCTAssertEqual(result[0], UInt8("00000000", radix: 2)) 89 | } 90 | 91 | func test_moreData_notPresent() { 92 | /* When */ 93 | let result = RowerDataCharacteristicFlags.create( 94 | moreDataPresent: false 95 | ) 96 | 97 | /* Then */ 98 | XCTAssertEqual(result[0], UInt8("00000001", radix: 2)) 99 | } 100 | 101 | func test_averageStrokeRate_present() { 102 | /* When */ 103 | let result = RowerDataCharacteristicFlags.create( 104 | moreDataPresent: true, 105 | averageStrokeRatePresent: true 106 | ) 107 | 108 | /* Then */ 109 | XCTAssertEqual(result[0], UInt8("00000010", radix: 2)) 110 | } 111 | 112 | func test_totalDistance_present() { 113 | /* When */ 114 | let result = RowerDataCharacteristicFlags.create( 115 | moreDataPresent: true, 116 | totalDistancePresent: true 117 | ) 118 | 119 | /* Then */ 120 | XCTAssertEqual(result[0], UInt8("00000100", radix: 2)) 121 | } 122 | 123 | func test_instantaneousPace_present() { 124 | /* When */ 125 | let result = RowerDataCharacteristicFlags.create( 126 | moreDataPresent: true, 127 | instantaneousPacePresent: true 128 | ) 129 | 130 | /* Then */ 131 | XCTAssertEqual(result[0], UInt8("00001000", radix: 2)) 132 | } 133 | 134 | func test_averagePace_present() { 135 | /* When */ 136 | let result = RowerDataCharacteristicFlags.create( 137 | moreDataPresent: true, 138 | averagePacePresent: true 139 | ) 140 | 141 | /* Then */ 142 | XCTAssertEqual(result[0], UInt8("00010000", radix: 2)) 143 | } 144 | 145 | func test_instantaneousPower_present() { 146 | /* When */ 147 | let result = RowerDataCharacteristicFlags.create( 148 | moreDataPresent: true, 149 | instantaneousPowerPresent: true 150 | ) 151 | 152 | /* Then */ 153 | XCTAssertEqual(result[0], UInt8("00100000", radix: 2)) 154 | } 155 | 156 | func test_averagePower_present() { 157 | /* When */ 158 | let result = RowerDataCharacteristicFlags.create( 159 | moreDataPresent: true, 160 | averagePowerPresent: true 161 | ) 162 | 163 | /* Then */ 164 | XCTAssertEqual(result[0], UInt8("01000000", radix: 2)) 165 | } 166 | 167 | func test_resistanceLevel_present() { 168 | /* When */ 169 | let result = RowerDataCharacteristicFlags.create( 170 | moreDataPresent: true, 171 | resistanceLevelPresent: true 172 | ) 173 | 174 | /* Then */ 175 | XCTAssertEqual(result[0], UInt8("10000000", radix: 2)) 176 | } 177 | 178 | func test_expendedEnergy_present() { 179 | /* When */ 180 | let result = RowerDataCharacteristicFlags.create( 181 | expendedEnergyPresent: true 182 | ) 183 | 184 | /* Then */ 185 | XCTAssertEqual(result[1], UInt8("00000001", radix: 2)) 186 | } 187 | 188 | func test_heartRate_present() { 189 | /* When */ 190 | let result = RowerDataCharacteristicFlags.create( 191 | heartRatePresent: true 192 | ) 193 | 194 | /* Then */ 195 | XCTAssertEqual(result[1], UInt8("00000010", radix: 2)) 196 | } 197 | 198 | func test_metabolicEquivalent_present() { 199 | /* When */ 200 | let result = RowerDataCharacteristicFlags.create( 201 | metabolicEquivalentPresent: true 202 | ) 203 | 204 | /* Then */ 205 | XCTAssertEqual(result[1], UInt8("00000100", radix: 2)) 206 | } 207 | 208 | func test_elapsedTime_present() { 209 | /* When */ 210 | let result = RowerDataCharacteristicFlags.create( 211 | elapsedTimePresent: true 212 | ) 213 | 214 | /* Then */ 215 | XCTAssertEqual(result[1], UInt8("00001000", radix: 2)) 216 | } 217 | 218 | func test_remainingTime_present() { 219 | /* When */ 220 | let result = RowerDataCharacteristicFlags.create( 221 | remainingTimePresent: true 222 | ) 223 | 224 | /* Then */ 225 | XCTAssertEqual(result[1], UInt8("00010000", radix: 2)) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /Sources/WaterRowerData-BLE/RowerDataCharacteristic.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /** 4 | A class that can decode raw bytes into `RowerData` instances. 5 | 6 | This class follows the Rower Data characteristic specification 7 | as described in section 4.8 "Rower Data" of the 8 | Fitness Machine Service (FTMS) Bluetooth Service specification, 9 | revision v1.0. 10 | 11 | A copy of this specification can be found on 12 | https://www.bluetooth.com/specifications/gatt/ 13 | */ 14 | public class RowerDataCharacteristic { 15 | 16 | /** 17 | The UUID value that identifies this characteristic. 18 | */ 19 | public static let uuid = UUID(uuidString: "00002AD1-0000-1000-8000-00805F9B34FB")! 20 | 21 | /** 22 | Decodes given `data` into a `RowerData` instance. 23 | 24 | Due to restrictions in the byte buffer size some of the `RowerData` 25 | properties will be absent, which is represented as a `nil` value. 26 | 27 | - Parameter data: A `Data` instance that contains the encoded data 28 | as described in the Rower Data characteristic 29 | specification. 30 | 31 | - Returns: A `RowerData` instance with the decoded properties. 32 | Properties will be `nil` if not present in the encoded 33 | data. 34 | */ 35 | public static func decode(data: Data) -> RowerData { 36 | return RowerData( 37 | strokeRate: strokeRate(from: data), 38 | strokeCount: strokeCount(from: data), 39 | averageStrokeRate: averageStrokeRate(from: data), 40 | totalDistanceMeters: totalDistanceMeters(from: data), 41 | instantaneousPaceSeconds: instantaneousPaceSeconds(from: data), 42 | averagePaceSeconds: averagePaceSeconds(from: data), 43 | instantaneousPowerWatts: instantaneousPower(from: data), 44 | averagePowerWatts: averagePower(from: data), 45 | resistanceLevel: resistanceLevel(from: data), 46 | totalEnergyKiloCalories: totalEnergy(from: data), 47 | energyPerHourKiloCalories: energyPerHour(from: data), 48 | energyPerMinuteKiloCalories: energyPerMinute(from: data), 49 | heartRate: heartRate(from: data), 50 | metabolicEquivalent: metabolicEquivalent(from: data), 51 | elapsedTimeSeconds: elapsedTime(from: data), 52 | remainingTimeSeconds: remainingTime(from: data) 53 | ) 54 | } 55 | 56 | private static let fields: [Field] = [ 57 | rowerDataFlagsField, 58 | rowerDataStrokeRateField, 59 | rowerDataStrokeCountField, 60 | rowerDataAverageStrokeRateField, 61 | rowerDataTotalDistanceField, 62 | rowerDataInstantaneousPaceField, 63 | rowerDataAveragePaceField, 64 | rowerDataInstantaneousPowerField, 65 | rowerDataAveragePowerField, 66 | rowerDataResistanceLevelField, 67 | rowerDataTotalEnergyField, 68 | rowerDataEnergyPerHourField, 69 | rowerDataEnergyPerMinuteField, 70 | rowerDataHeartRateField, 71 | rowerDataMetabolicEquivalentField, 72 | rowerDataElapsedTimeField, 73 | rowerDataRemainingTimeField 74 | ] 75 | 76 | private static func strokeRate(from data: Data) -> Double? { 77 | guard let intValue = readIntValue(from: data, for: rowerDataStrokeRateField) else { 78 | return nil 79 | } 80 | 81 | return Double(intValue) / 2.0 82 | } 83 | 84 | private static func strokeCount(from data: Data) -> Int? { 85 | return readIntValue(from: data, for: rowerDataStrokeCountField) 86 | } 87 | 88 | private static func averageStrokeRate(from data: Data) -> Double? { 89 | guard let intValue = readIntValue(from: data, for: rowerDataAverageStrokeRateField) else { 90 | return nil 91 | } 92 | 93 | return Double(intValue) / 2.0 94 | } 95 | 96 | private static func totalDistanceMeters(from data: Data) -> Int? { 97 | return readIntValue(from: data, for: rowerDataTotalDistanceField) 98 | } 99 | 100 | private static func instantaneousPaceSeconds(from data: Data) -> Int? { 101 | return readIntValue(from: data, for: rowerDataInstantaneousPaceField) 102 | } 103 | 104 | private static func averagePaceSeconds(from data: Data) -> Int? { 105 | return readIntValue(from: data, for: rowerDataAveragePaceField) 106 | } 107 | 108 | private static func instantaneousPower(from data: Data) -> Int? { 109 | return readIntValue(from: data, for: rowerDataInstantaneousPowerField) 110 | } 111 | 112 | private static func averagePower(from data: Data) -> Int? { 113 | return readIntValue(from: data, for: rowerDataAveragePowerField) 114 | } 115 | 116 | private static func resistanceLevel(from data: Data) -> Int? { 117 | return readIntValue(from: data, for: rowerDataResistanceLevelField) 118 | } 119 | 120 | private static func totalEnergy(from data: Data) -> Int? { 121 | let result = readIntValue(from: data, for: rowerDataTotalEnergyField) 122 | if result == 0xFFFF { 123 | return nil 124 | } 125 | 126 | return result 127 | } 128 | 129 | private static func energyPerHour(from data: Data) -> Int? { 130 | let result = readIntValue(from: data, for: rowerDataEnergyPerHourField) 131 | if result == 0xFFFF { 132 | return nil 133 | } 134 | 135 | return result 136 | } 137 | 138 | private static func energyPerMinute(from data: Data) -> Int? { 139 | let result = readIntValue(from: data, for: rowerDataEnergyPerMinuteField) 140 | if result == 0xFF { 141 | return nil 142 | } 143 | 144 | return result 145 | } 146 | 147 | private static func heartRate(from data: Data) -> Int? { 148 | return readIntValue(from: data, for: rowerDataHeartRateField) 149 | } 150 | 151 | private static func metabolicEquivalent(from data: Data) -> Double? { 152 | guard let intValue = readIntValue(from: data, for: rowerDataMetabolicEquivalentField) else { 153 | return nil 154 | } 155 | 156 | return Double(intValue) / 10 157 | } 158 | 159 | private static func elapsedTime(from data: Data) -> Int? { 160 | return readIntValue(from: data, for: rowerDataElapsedTimeField) 161 | } 162 | 163 | private static func remainingTime(from data: Data) -> Int? { 164 | return readIntValue(from: data, for: rowerDataRemainingTimeField) 165 | } 166 | 167 | private static func readIntValue(from data: Data, for field: Field) -> Int? { 168 | if !field.isPresent(in: data) { 169 | return nil 170 | } 171 | 172 | var offset = 0 173 | for i in 0.. Cancellable { 77 | listeners.append(listener) 78 | listener.onConnectionStateChanged(connectionState) 79 | return CancelListening(self, listener) 80 | } 81 | 82 | /** 83 | A `CBCentralManagerDelegate` which initiates a connection with the peripheral when the 84 | `CBCentralManager` is ready. 85 | */ 86 | private class BLEConnectionDelegate: NSObject, CBCentralManagerDelegate { 87 | 88 | private unowned let parent: CBConnection 89 | private let peripheralIdentifier: UUID 90 | 91 | // swiftlint:disable weak_delegate 92 | private let peripheralDelegate: BleConnectionPeripheralDelegate 93 | 94 | init( 95 | _ peripheralIdentifier: UUID, 96 | _ parent: CBConnection 97 | ) { 98 | self.parent = parent 99 | self.peripheralIdentifier = peripheralIdentifier 100 | peripheralDelegate = BleConnectionPeripheralDelegate(parent) 101 | } 102 | 103 | /** 104 | The peripheral that is connected or being connected to. 105 | If the connection isn't set up yet or is disconnected, this value is nil. 106 | */ 107 | private var peripheral: CBPeripheral? 108 | 109 | /** 110 | Whether the connection was cancelled, by invoking `disconnect()`. 111 | */ 112 | private var connectionCancelled = false { 113 | didSet { peripheralDelegate.connectionCancelled = connectionCancelled } 114 | } 115 | 116 | func centralManagerDidUpdateState(_ central: CBCentralManager) { 117 | os_log("centralManagerDidUpdateState %d", log: log, type: .debug, central.state.rawValue) 118 | 119 | guard peripheral == nil else { 120 | os_log("Already connected", log: log, type: .debug) 121 | return 122 | } 123 | 124 | if connectionCancelled { 125 | os_log("Connection cancelled", log: log, type: .debug) 126 | return 127 | } 128 | 129 | guard central.state == .poweredOn else { 130 | os_log("Invalid state, not connecting: %d", log: log, type: .error, central.state.rawValue) 131 | parent.connectionState = .failed 132 | return 133 | } 134 | 135 | guard let peripheral = central.retrievePeripherals(withIdentifiers: [peripheralIdentifier]).first else { 136 | os_log("Could not find peripheral with id %@", log: log, type: .error, peripheralIdentifier.uuidString) 137 | parent.connectionState = .failed 138 | return 139 | } 140 | 141 | os_log("Connecting to %@", log: log, type: .info, peripheral) 142 | self.peripheral = peripheral 143 | parent.connectionState = .connecting 144 | central.connect(peripheral, options: nil) 145 | } 146 | 147 | private var isDiscoveringServices = false 148 | 149 | func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { 150 | if connectionCancelled { 151 | os_log("Manager did connect to peripheral, but connection was cancelled", log: log, type: .error) 152 | central.cancelPeripheralConnection(peripheral) 153 | return 154 | } 155 | 156 | if self.peripheral != peripheral { 157 | os_log("Manager did connect to different peripheral, disconnecting", log: log, type: .error) 158 | central.cancelPeripheralConnection(peripheral) 159 | return 160 | } 161 | 162 | os_log("centralManager didConnect %@", log: log, type: .debug, peripheral) 163 | if isDiscoveringServices { 164 | os_log("Already discovering services") 165 | return 166 | } 167 | 168 | os_log("Discovering services", log: log, type: .debug) 169 | isDiscoveringServices = true 170 | peripheral.delegate = peripheralDelegate 171 | peripheral.discoverServices(nil) 172 | } 173 | 174 | func centralManager( 175 | _ central: CBCentralManager, 176 | didDisconnectPeripheral peripheral: CBPeripheral, 177 | error: Error? 178 | ) { 179 | os_log("centralManager didDisconnectPeripheral %@", log: log, type: .debug, peripheral) 180 | if self.peripheral != peripheral { 181 | os_log("Disconnected peripheral differs from wanted peripheral") 182 | return 183 | } 184 | 185 | parent.delegate = nil 186 | parent.manager = nil 187 | parent.connectionState = .disconnected 188 | } 189 | 190 | func disconnect(_ manager: CBCentralManager) { 191 | guard let peripheral = peripheral else { 192 | os_log("Peripheral connection not yet initiated, cancelling", log: log, type: .error) 193 | connectionCancelled = true 194 | 195 | parent.delegate = nil 196 | parent.manager = nil 197 | return 198 | } 199 | 200 | manager.cancelPeripheralConnection(peripheral) 201 | connectionCancelled = true 202 | } 203 | } 204 | 205 | /** 206 | A `CBPeripheralDelegate` which sets up the connection with the peripheral once initiated. 207 | */ 208 | private class BleConnectionPeripheralDelegate: NSObject, CBPeripheralDelegate { 209 | 210 | private unowned let parent: CBConnection 211 | var connectionCancelled = false 212 | 213 | init( 214 | _ parent: CBConnection 215 | ) { 216 | self.parent = parent 217 | } 218 | 219 | private var pendingServices: [CBService] = [] 220 | 221 | func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { 222 | if connectionCancelled { 223 | os_log("Peripheral did discover services but connection was cancelled.", log: log, type: .debug) 224 | return 225 | } 226 | 227 | if let error = error { 228 | os_log( 229 | "An error occurred while discovering services: %@", 230 | log: log, 231 | type: .error, 232 | String(describing: error) 233 | ) 234 | parent.connectionState = .failed 235 | return 236 | } 237 | 238 | os_log("peripheral didDiscoverServices %@", log: log, type: .debug, peripheral.services!) 239 | guard let services = peripheral.services else { 240 | os_log("Device has no services", log: log, type: .error) 241 | parent.connectionState = .failed 242 | return 243 | } 244 | 245 | pendingServices = services 246 | continueDiscovering(peripheral) 247 | } 248 | 249 | func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { 250 | if connectionCancelled { 251 | os_log("Peripheral did discover characteristics but connection was cancelled", log: log, type: .debug) 252 | return 253 | } 254 | 255 | if let error = error { 256 | os_log( 257 | "An error occurred while discovering characteristics: %@", 258 | log: log, 259 | type: .error, 260 | String(describing: error) 261 | ) 262 | parent.connectionState = .failed 263 | return 264 | } 265 | 266 | os_log( 267 | "peripheral didDiscoverCharacteristicsFor %@(%@): %@", 268 | log: log, 269 | type: .debug, 270 | service, 271 | service.uuid.uuidString, 272 | service.characteristics! 273 | ) 274 | continueDiscovering(peripheral) 275 | } 276 | 277 | private func continueDiscovering(_ peripheral: CBPeripheral) { 278 | guard let service = pendingServices.first else { 279 | os_log("Done discovering services and characteristics", log: log, type: .debug) 280 | parent.connectionState = .connected(device: CBConnectedDevice(from: peripheral)) 281 | return 282 | } 283 | 284 | pendingServices.removeAll { s -> Bool in 285 | s === service 286 | } 287 | os_log("Discovering characteristics for %@(%@)", log: log, type: .debug, service, service.uuid.uuidString) 288 | peripheral.discoverCharacteristics(nil, for: service) 289 | } 290 | } 291 | 292 | private class CancelListening: Cancellable { 293 | 294 | private weak var parent: CBConnection? 295 | private let listener: CBConnectionStateListener 296 | 297 | init( 298 | _ parent: CBConnection, 299 | _ listener: CBConnectionStateListener 300 | ) { 301 | self.parent = parent 302 | self.listener = listener 303 | } 304 | 305 | func cancel() { 306 | parent?.listeners.removeAll { l in l === listener } 307 | } 308 | 309 | deinit { 310 | cancel() 311 | } 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /iOS Example/iOS Example.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 8C40E20A24BF57D0004AE938 /* DeviceDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E1E424BF57D0004AE938 /* DeviceDetailsViewModel.swift */; }; 11 | 8C40E20B24BF57D0004AE938 /* ConnectionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E1E524BF57D0004AE938 /* ConnectionStatus.swift */; }; 12 | 8C40E20C24BF57D0004AE938 /* DeviceDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E1E624BF57D0004AE938 /* DeviceDetailsView.swift */; }; 13 | 8C40E20D24BF57D0004AE938 /* DeviceListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E1E824BF57D0004AE938 /* DeviceListView.swift */; }; 14 | 8C40E20E24BF57D0004AE938 /* Device.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E1E924BF57D0004AE938 /* Device.swift */; }; 15 | 8C40E20F24BF57D0004AE938 /* DevicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E1EA24BF57D0004AE938 /* DevicesViewModel.swift */; }; 16 | 8C40E21024BF57D0004AE938 /* DevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E1EB24BF57D0004AE938 /* DevicesView.swift */; }; 17 | 8C40E21124BF57D0004AE938 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E1EC24BF57D0004AE938 /* SceneDelegate.swift */; }; 18 | 8C40E21224BF57D0004AE938 /* Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 8C40E1ED24BF57D0004AE938 /* Info.plist */; }; 19 | 8C40E21324BF57D0004AE938 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 8C40E1EF24BF57D0004AE938 /* LaunchScreen.storyboard */; }; 20 | 8C40E21424BF57D0004AE938 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8C40E1F224BF57D0004AE938 /* Preview Assets.xcassets */; }; 21 | 8C40E21524BF57D0004AE938 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8C40E1F324BF57D0004AE938 /* Assets.xcassets */; }; 22 | 8C40E21724BF57D0004AE938 /* CBConnectionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E1F824BF57D0004AE938 /* CBConnectionFactory.swift */; }; 23 | 8C40E21824BF57D0004AE938 /* CBConnectedDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E1F924BF57D0004AE938 /* CBConnectedDevice.swift */; }; 24 | 8C40E21924BF57D0004AE938 /* CBConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E1FA24BF57D0004AE938 /* CBConnection.swift */; }; 25 | 8C40E21B24BF57D0004AE938 /* CBConnectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E1FC24BF57D0004AE938 /* CBConnectionState.swift */; }; 26 | 8C40E21C24BF57D0004AE938 /* CBConnectionStateListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E1FD24BF57D0004AE938 /* CBConnectionStateListener.swift */; }; 27 | 8C40E21F24BF57D0004AE938 /* CBScanResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E20224BF57D0004AE938 /* CBScanResult.swift */; }; 28 | 8C40E22124BF57D0004AE938 /* ConnectedRowerDataDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E20524BF57D0004AE938 /* ConnectedRowerDataDevice.swift */; }; 29 | 8C40E22224BF57D0004AE938 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E20724BF57D0004AE938 /* AppDelegate.swift */; }; 30 | 8C40E22324BF57D0004AE938 /* Cancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C40E20924BF57D0004AE938 /* Cancellable.swift */; }; 31 | 8C66A09A24C99B07006DD6AE /* CBScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C66A09924C99B07006DD6AE /* CBScanner.swift */; }; 32 | 8CA305E524C1BC480073B763 /* WaterRowerData-BLE in Frameworks */ = {isa = PBXBuildFile; productRef = 8CA305E424C1BC480073B763 /* WaterRowerData-BLE */; }; 33 | /* End PBXBuildFile section */ 34 | 35 | /* Begin PBXFileReference section */ 36 | 8C40E18524BF555C004AE938 /* iOS Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "iOS Example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 37 | 8C40E1E424BF57D0004AE938 /* DeviceDetailsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceDetailsViewModel.swift; sourceTree = ""; }; 38 | 8C40E1E524BF57D0004AE938 /* ConnectionStatus.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectionStatus.swift; sourceTree = ""; }; 39 | 8C40E1E624BF57D0004AE938 /* DeviceDetailsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceDetailsView.swift; sourceTree = ""; }; 40 | 8C40E1E824BF57D0004AE938 /* DeviceListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceListView.swift; sourceTree = ""; }; 41 | 8C40E1E924BF57D0004AE938 /* Device.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Device.swift; sourceTree = ""; }; 42 | 8C40E1EA24BF57D0004AE938 /* DevicesViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DevicesViewModel.swift; sourceTree = ""; }; 43 | 8C40E1EB24BF57D0004AE938 /* DevicesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DevicesView.swift; sourceTree = ""; }; 44 | 8C40E1EC24BF57D0004AE938 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 45 | 8C40E1ED24BF57D0004AE938 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 46 | 8C40E1F024BF57D0004AE938 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = LaunchScreen.storyboard; sourceTree = ""; }; 47 | 8C40E1F224BF57D0004AE938 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 48 | 8C40E1F324BF57D0004AE938 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 49 | 8C40E1F824BF57D0004AE938 /* CBConnectionFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CBConnectionFactory.swift; sourceTree = ""; }; 50 | 8C40E1F924BF57D0004AE938 /* CBConnectedDevice.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CBConnectedDevice.swift; sourceTree = ""; }; 51 | 8C40E1FA24BF57D0004AE938 /* CBConnection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CBConnection.swift; sourceTree = ""; }; 52 | 8C40E1FC24BF57D0004AE938 /* CBConnectionState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CBConnectionState.swift; sourceTree = ""; }; 53 | 8C40E1FD24BF57D0004AE938 /* CBConnectionStateListener.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CBConnectionStateListener.swift; sourceTree = ""; }; 54 | 8C40E20224BF57D0004AE938 /* CBScanResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CBScanResult.swift; sourceTree = ""; }; 55 | 8C40E20524BF57D0004AE938 /* ConnectedRowerDataDevice.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectedRowerDataDevice.swift; sourceTree = ""; }; 56 | 8C40E20624BF57D0004AE938 /* iOS Example.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = "iOS Example.entitlements"; sourceTree = ""; }; 57 | 8C40E20724BF57D0004AE938 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 58 | 8C40E20924BF57D0004AE938 /* Cancellable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Cancellable.swift; sourceTree = ""; }; 59 | 8C66A09924C99B07006DD6AE /* CBScanner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CBScanner.swift; sourceTree = ""; }; 60 | /* End PBXFileReference section */ 61 | 62 | /* Begin PBXFrameworksBuildPhase section */ 63 | 8C40E18224BF555C004AE938 /* Frameworks */ = { 64 | isa = PBXFrameworksBuildPhase; 65 | buildActionMask = 2147483647; 66 | files = ( 67 | 8CA305E524C1BC480073B763 /* WaterRowerData-BLE in Frameworks */, 68 | ); 69 | runOnlyForDeploymentPostprocessing = 0; 70 | }; 71 | /* End PBXFrameworksBuildPhase section */ 72 | 73 | /* Begin PBXGroup section */ 74 | 8C40E17C24BF555C004AE938 = { 75 | isa = PBXGroup; 76 | children = ( 77 | 8C40E18724BF555C004AE938 /* iOS Example */, 78 | 8C40E18624BF555C004AE938 /* Products */, 79 | 8C40E19C24BF5623004AE938 /* Frameworks */, 80 | ); 81 | sourceTree = ""; 82 | }; 83 | 8C40E18624BF555C004AE938 /* Products */ = { 84 | isa = PBXGroup; 85 | children = ( 86 | 8C40E18524BF555C004AE938 /* iOS Example.app */, 87 | ); 88 | name = Products; 89 | sourceTree = ""; 90 | }; 91 | 8C40E18724BF555C004AE938 /* iOS Example */ = { 92 | isa = PBXGroup; 93 | children = ( 94 | 8C40E20724BF57D0004AE938 /* AppDelegate.swift */, 95 | 8C40E1F324BF57D0004AE938 /* Assets.xcassets */, 96 | 8C40E1EE24BF57D0004AE938 /* Base.lproj */, 97 | 8C40E1F424BF57D0004AE938 /* Bluetooth */, 98 | 8C40E20624BF57D0004AE938 /* iOS Example.entitlements */, 99 | 8C40E1ED24BF57D0004AE938 /* Info.plist */, 100 | 8C40E1F124BF57D0004AE938 /* Preview Content */, 101 | 8C40E1EC24BF57D0004AE938 /* SceneDelegate.swift */, 102 | 8C40E1E224BF57D0004AE938 /* UI */, 103 | 8C40E20824BF57D0004AE938 /* Util */, 104 | ); 105 | path = "iOS Example"; 106 | sourceTree = ""; 107 | }; 108 | 8C40E19C24BF5623004AE938 /* Frameworks */ = { 109 | isa = PBXGroup; 110 | children = ( 111 | ); 112 | name = Frameworks; 113 | sourceTree = ""; 114 | }; 115 | 8C40E1E224BF57D0004AE938 /* UI */ = { 116 | isa = PBXGroup; 117 | children = ( 118 | 8C40E1E324BF57D0004AE938 /* DeviceDetails */, 119 | 8C40E1E724BF57D0004AE938 /* Devices */, 120 | ); 121 | path = UI; 122 | sourceTree = ""; 123 | }; 124 | 8C40E1E324BF57D0004AE938 /* DeviceDetails */ = { 125 | isa = PBXGroup; 126 | children = ( 127 | 8C40E1E424BF57D0004AE938 /* DeviceDetailsViewModel.swift */, 128 | 8C40E1E524BF57D0004AE938 /* ConnectionStatus.swift */, 129 | 8C40E1E624BF57D0004AE938 /* DeviceDetailsView.swift */, 130 | ); 131 | path = DeviceDetails; 132 | sourceTree = ""; 133 | }; 134 | 8C40E1E724BF57D0004AE938 /* Devices */ = { 135 | isa = PBXGroup; 136 | children = ( 137 | 8C40E1E824BF57D0004AE938 /* DeviceListView.swift */, 138 | 8C40E1E924BF57D0004AE938 /* Device.swift */, 139 | 8C40E1EA24BF57D0004AE938 /* DevicesViewModel.swift */, 140 | 8C40E1EB24BF57D0004AE938 /* DevicesView.swift */, 141 | ); 142 | path = Devices; 143 | sourceTree = ""; 144 | }; 145 | 8C40E1EE24BF57D0004AE938 /* Base.lproj */ = { 146 | isa = PBXGroup; 147 | children = ( 148 | 8C40E1EF24BF57D0004AE938 /* LaunchScreen.storyboard */, 149 | ); 150 | path = Base.lproj; 151 | sourceTree = ""; 152 | }; 153 | 8C40E1F124BF57D0004AE938 /* Preview Content */ = { 154 | isa = PBXGroup; 155 | children = ( 156 | 8C40E1F224BF57D0004AE938 /* Preview Assets.xcassets */, 157 | ); 158 | path = "Preview Content"; 159 | sourceTree = ""; 160 | }; 161 | 8C40E1F424BF57D0004AE938 /* Bluetooth */ = { 162 | isa = PBXGroup; 163 | children = ( 164 | 8C40E1F524BF57D0004AE938 /* Connection */, 165 | 8C40E1FE24BF57D0004AE938 /* Discovery */, 166 | 8C40E20424BF57D0004AE938 /* RowerData */, 167 | ); 168 | path = Bluetooth; 169 | sourceTree = ""; 170 | }; 171 | 8C40E1F524BF57D0004AE938 /* Connection */ = { 172 | isa = PBXGroup; 173 | children = ( 174 | 8C40E1FA24BF57D0004AE938 /* CBConnection.swift */, 175 | 8C40E1F824BF57D0004AE938 /* CBConnectionFactory.swift */, 176 | 8C40E1FC24BF57D0004AE938 /* CBConnectionState.swift */, 177 | 8C40E1FD24BF57D0004AE938 /* CBConnectionStateListener.swift */, 178 | 8C40E1F924BF57D0004AE938 /* CBConnectedDevice.swift */, 179 | ); 180 | path = Connection; 181 | sourceTree = ""; 182 | }; 183 | 8C40E1FE24BF57D0004AE938 /* Discovery */ = { 184 | isa = PBXGroup; 185 | children = ( 186 | 8C66A09924C99B07006DD6AE /* CBScanner.swift */, 187 | 8C40E20224BF57D0004AE938 /* CBScanResult.swift */, 188 | ); 189 | path = Discovery; 190 | sourceTree = ""; 191 | }; 192 | 8C40E20424BF57D0004AE938 /* RowerData */ = { 193 | isa = PBXGroup; 194 | children = ( 195 | 8C40E20524BF57D0004AE938 /* ConnectedRowerDataDevice.swift */, 196 | ); 197 | path = RowerData; 198 | sourceTree = ""; 199 | }; 200 | 8C40E20824BF57D0004AE938 /* Util */ = { 201 | isa = PBXGroup; 202 | children = ( 203 | 8C40E20924BF57D0004AE938 /* Cancellable.swift */, 204 | ); 205 | path = Util; 206 | sourceTree = ""; 207 | }; 208 | /* End PBXGroup section */ 209 | 210 | /* Begin PBXNativeTarget section */ 211 | 8C40E18424BF555C004AE938 /* iOS Example */ = { 212 | isa = PBXNativeTarget; 213 | buildConfigurationList = 8C40E19924BF555D004AE938 /* Build configuration list for PBXNativeTarget "iOS Example" */; 214 | buildPhases = ( 215 | 8C40E18124BF555C004AE938 /* Sources */, 216 | 8C40E18224BF555C004AE938 /* Frameworks */, 217 | 8C40E18324BF555C004AE938 /* Resources */, 218 | ); 219 | buildRules = ( 220 | ); 221 | dependencies = ( 222 | ); 223 | name = "iOS Example"; 224 | packageProductDependencies = ( 225 | 8CA305E424C1BC480073B763 /* WaterRowerData-BLE */, 226 | ); 227 | productName = "iOS Example"; 228 | productReference = 8C40E18524BF555C004AE938 /* iOS Example.app */; 229 | productType = "com.apple.product-type.application"; 230 | }; 231 | /* End PBXNativeTarget section */ 232 | 233 | /* Begin PBXProject section */ 234 | 8C40E17D24BF555C004AE938 /* Project object */ = { 235 | isa = PBXProject; 236 | attributes = { 237 | LastSwiftUpdateCheck = 1150; 238 | LastUpgradeCheck = 1150; 239 | ORGANIZATIONNAME = WaterRower; 240 | TargetAttributes = { 241 | 8C40E18424BF555C004AE938 = { 242 | CreatedOnToolsVersion = 11.5; 243 | LastSwiftMigration = 1150; 244 | }; 245 | }; 246 | }; 247 | buildConfigurationList = 8C40E18024BF555C004AE938 /* Build configuration list for PBXProject "iOS Example" */; 248 | compatibilityVersion = "Xcode 9.3"; 249 | developmentRegion = en; 250 | hasScannedForEncodings = 0; 251 | knownRegions = ( 252 | en, 253 | Base, 254 | ); 255 | mainGroup = 8C40E17C24BF555C004AE938; 256 | productRefGroup = 8C40E18624BF555C004AE938 /* Products */; 257 | projectDirPath = ""; 258 | projectRoot = ""; 259 | targets = ( 260 | 8C40E18424BF555C004AE938 /* iOS Example */, 261 | ); 262 | }; 263 | /* End PBXProject section */ 264 | 265 | /* Begin PBXResourcesBuildPhase section */ 266 | 8C40E18324BF555C004AE938 /* Resources */ = { 267 | isa = PBXResourcesBuildPhase; 268 | buildActionMask = 2147483647; 269 | files = ( 270 | 8C40E21224BF57D0004AE938 /* Info.plist in Resources */, 271 | 8C40E21424BF57D0004AE938 /* Preview Assets.xcassets in Resources */, 272 | 8C40E21524BF57D0004AE938 /* Assets.xcassets in Resources */, 273 | 8C40E21324BF57D0004AE938 /* LaunchScreen.storyboard in Resources */, 274 | ); 275 | runOnlyForDeploymentPostprocessing = 0; 276 | }; 277 | /* End PBXResourcesBuildPhase section */ 278 | 279 | /* Begin PBXSourcesBuildPhase section */ 280 | 8C40E18124BF555C004AE938 /* Sources */ = { 281 | isa = PBXSourcesBuildPhase; 282 | buildActionMask = 2147483647; 283 | files = ( 284 | 8C40E20A24BF57D0004AE938 /* DeviceDetailsViewModel.swift in Sources */, 285 | 8C40E20B24BF57D0004AE938 /* ConnectionStatus.swift in Sources */, 286 | 8C40E20D24BF57D0004AE938 /* DeviceListView.swift in Sources */, 287 | 8C40E20C24BF57D0004AE938 /* DeviceDetailsView.swift in Sources */, 288 | 8C40E21724BF57D0004AE938 /* CBConnectionFactory.swift in Sources */, 289 | 8C40E20F24BF57D0004AE938 /* DevicesViewModel.swift in Sources */, 290 | 8C40E21824BF57D0004AE938 /* CBConnectedDevice.swift in Sources */, 291 | 8C40E21924BF57D0004AE938 /* CBConnection.swift in Sources */, 292 | 8C40E21B24BF57D0004AE938 /* CBConnectionState.swift in Sources */, 293 | 8C40E21F24BF57D0004AE938 /* CBScanResult.swift in Sources */, 294 | 8C66A09A24C99B07006DD6AE /* CBScanner.swift in Sources */, 295 | 8C40E21C24BF57D0004AE938 /* CBConnectionStateListener.swift in Sources */, 296 | 8C40E20E24BF57D0004AE938 /* Device.swift in Sources */, 297 | 8C40E21024BF57D0004AE938 /* DevicesView.swift in Sources */, 298 | 8C40E22124BF57D0004AE938 /* ConnectedRowerDataDevice.swift in Sources */, 299 | 8C40E22324BF57D0004AE938 /* Cancellable.swift in Sources */, 300 | 8C40E22224BF57D0004AE938 /* AppDelegate.swift in Sources */, 301 | 8C40E21124BF57D0004AE938 /* SceneDelegate.swift in Sources */, 302 | ); 303 | runOnlyForDeploymentPostprocessing = 0; 304 | }; 305 | /* End PBXSourcesBuildPhase section */ 306 | 307 | /* Begin PBXVariantGroup section */ 308 | 8C40E1EF24BF57D0004AE938 /* LaunchScreen.storyboard */ = { 309 | isa = PBXVariantGroup; 310 | children = ( 311 | 8C40E1F024BF57D0004AE938 /* Base */, 312 | ); 313 | name = LaunchScreen.storyboard; 314 | sourceTree = ""; 315 | }; 316 | /* End PBXVariantGroup section */ 317 | 318 | /* Begin XCBuildConfiguration section */ 319 | 8C40E19724BF555D004AE938 /* Debug */ = { 320 | isa = XCBuildConfiguration; 321 | buildSettings = { 322 | ALWAYS_SEARCH_USER_PATHS = NO; 323 | CLANG_ANALYZER_NONNULL = YES; 324 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 325 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 326 | CLANG_CXX_LIBRARY = "libc++"; 327 | CLANG_ENABLE_MODULES = YES; 328 | CLANG_ENABLE_OBJC_ARC = YES; 329 | CLANG_ENABLE_OBJC_WEAK = YES; 330 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 331 | CLANG_WARN_BOOL_CONVERSION = YES; 332 | CLANG_WARN_COMMA = YES; 333 | CLANG_WARN_CONSTANT_CONVERSION = YES; 334 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 335 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 336 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 337 | CLANG_WARN_EMPTY_BODY = YES; 338 | CLANG_WARN_ENUM_CONVERSION = YES; 339 | CLANG_WARN_INFINITE_RECURSION = YES; 340 | CLANG_WARN_INT_CONVERSION = YES; 341 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 342 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 343 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 344 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 345 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 346 | CLANG_WARN_STRICT_PROTOTYPES = YES; 347 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 348 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 349 | CLANG_WARN_UNREACHABLE_CODE = YES; 350 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 351 | COPY_PHASE_STRIP = NO; 352 | DEBUG_INFORMATION_FORMAT = dwarf; 353 | ENABLE_STRICT_OBJC_MSGSEND = YES; 354 | ENABLE_TESTABILITY = YES; 355 | GCC_C_LANGUAGE_STANDARD = gnu11; 356 | GCC_DYNAMIC_NO_PIC = NO; 357 | GCC_NO_COMMON_BLOCKS = YES; 358 | GCC_OPTIMIZATION_LEVEL = 0; 359 | GCC_PREPROCESSOR_DEFINITIONS = ( 360 | "DEBUG=1", 361 | "$(inherited)", 362 | ); 363 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 364 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 365 | GCC_WARN_UNDECLARED_SELECTOR = YES; 366 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 367 | GCC_WARN_UNUSED_FUNCTION = YES; 368 | GCC_WARN_UNUSED_VARIABLE = YES; 369 | IPHONEOS_DEPLOYMENT_TARGET = 13.5; 370 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 371 | MTL_FAST_MATH = YES; 372 | ONLY_ACTIVE_ARCH = YES; 373 | SDKROOT = iphoneos; 374 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 375 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 376 | }; 377 | name = Debug; 378 | }; 379 | 8C40E19824BF555D004AE938 /* Release */ = { 380 | isa = XCBuildConfiguration; 381 | buildSettings = { 382 | ALWAYS_SEARCH_USER_PATHS = NO; 383 | CLANG_ANALYZER_NONNULL = YES; 384 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 385 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 386 | CLANG_CXX_LIBRARY = "libc++"; 387 | CLANG_ENABLE_MODULES = YES; 388 | CLANG_ENABLE_OBJC_ARC = YES; 389 | CLANG_ENABLE_OBJC_WEAK = YES; 390 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 391 | CLANG_WARN_BOOL_CONVERSION = YES; 392 | CLANG_WARN_COMMA = YES; 393 | CLANG_WARN_CONSTANT_CONVERSION = YES; 394 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 395 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 396 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 397 | CLANG_WARN_EMPTY_BODY = YES; 398 | CLANG_WARN_ENUM_CONVERSION = YES; 399 | CLANG_WARN_INFINITE_RECURSION = YES; 400 | CLANG_WARN_INT_CONVERSION = YES; 401 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 402 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 403 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 404 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 405 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 406 | CLANG_WARN_STRICT_PROTOTYPES = YES; 407 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 408 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 409 | CLANG_WARN_UNREACHABLE_CODE = YES; 410 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 411 | COPY_PHASE_STRIP = NO; 412 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 413 | ENABLE_NS_ASSERTIONS = NO; 414 | ENABLE_STRICT_OBJC_MSGSEND = YES; 415 | GCC_C_LANGUAGE_STANDARD = gnu11; 416 | GCC_NO_COMMON_BLOCKS = YES; 417 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 418 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 419 | GCC_WARN_UNDECLARED_SELECTOR = YES; 420 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 421 | GCC_WARN_UNUSED_FUNCTION = YES; 422 | GCC_WARN_UNUSED_VARIABLE = YES; 423 | IPHONEOS_DEPLOYMENT_TARGET = 13.5; 424 | MTL_ENABLE_DEBUG_INFO = NO; 425 | MTL_FAST_MATH = YES; 426 | SDKROOT = iphoneos; 427 | SWIFT_COMPILATION_MODE = wholemodule; 428 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 429 | VALIDATE_PRODUCT = YES; 430 | }; 431 | name = Release; 432 | }; 433 | 8C40E19A24BF555D004AE938 /* Debug */ = { 434 | isa = XCBuildConfiguration; 435 | buildSettings = { 436 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 437 | CLANG_ENABLE_MODULES = YES; 438 | CODE_SIGN_ENTITLEMENTS = "iOS Example/iOS Example.entitlements"; 439 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; 440 | CODE_SIGN_STYLE = Automatic; 441 | DEVELOPMENT_ASSET_PATHS = "\"iOS Example/Preview Content\""; 442 | DEVELOPMENT_TEAM = CMQS4E74X4; 443 | ENABLE_PREVIEWS = YES; 444 | INFOPLIST_FILE = "iOS Example/Info.plist"; 445 | LD_RUNPATH_SEARCH_PATHS = ( 446 | "$(inherited)", 447 | "@executable_path/Frameworks", 448 | ); 449 | PRODUCT_BUNDLE_IDENTIFIER = "uk.co.waterrower.iOS-Example"; 450 | PRODUCT_NAME = "$(TARGET_NAME)"; 451 | SUPPORTS_MACCATALYST = YES; 452 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 453 | SWIFT_VERSION = 5.0; 454 | TARGETED_DEVICE_FAMILY = "1,2"; 455 | }; 456 | name = Debug; 457 | }; 458 | 8C40E19B24BF555D004AE938 /* Release */ = { 459 | isa = XCBuildConfiguration; 460 | buildSettings = { 461 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 462 | CLANG_ENABLE_MODULES = YES; 463 | CODE_SIGN_ENTITLEMENTS = "iOS Example/iOS Example.entitlements"; 464 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; 465 | CODE_SIGN_STYLE = Automatic; 466 | DEVELOPMENT_ASSET_PATHS = "\"iOS Example/Preview Content\""; 467 | DEVELOPMENT_TEAM = CMQS4E74X4; 468 | ENABLE_PREVIEWS = YES; 469 | INFOPLIST_FILE = "iOS Example/Info.plist"; 470 | LD_RUNPATH_SEARCH_PATHS = ( 471 | "$(inherited)", 472 | "@executable_path/Frameworks", 473 | ); 474 | PRODUCT_BUNDLE_IDENTIFIER = "uk.co.waterrower.iOS-Example"; 475 | PRODUCT_NAME = "$(TARGET_NAME)"; 476 | SUPPORTS_MACCATALYST = YES; 477 | SWIFT_VERSION = 5.0; 478 | TARGETED_DEVICE_FAMILY = "1,2"; 479 | }; 480 | name = Release; 481 | }; 482 | /* End XCBuildConfiguration section */ 483 | 484 | /* Begin XCConfigurationList section */ 485 | 8C40E18024BF555C004AE938 /* Build configuration list for PBXProject "iOS Example" */ = { 486 | isa = XCConfigurationList; 487 | buildConfigurations = ( 488 | 8C40E19724BF555D004AE938 /* Debug */, 489 | 8C40E19824BF555D004AE938 /* Release */, 490 | ); 491 | defaultConfigurationIsVisible = 0; 492 | defaultConfigurationName = Release; 493 | }; 494 | 8C40E19924BF555D004AE938 /* Build configuration list for PBXNativeTarget "iOS Example" */ = { 495 | isa = XCConfigurationList; 496 | buildConfigurations = ( 497 | 8C40E19A24BF555D004AE938 /* Debug */, 498 | 8C40E19B24BF555D004AE938 /* Release */, 499 | ); 500 | defaultConfigurationIsVisible = 0; 501 | defaultConfigurationName = Release; 502 | }; 503 | /* End XCConfigurationList section */ 504 | 505 | /* Begin XCSwiftPackageProductDependency section */ 506 | 8CA305E424C1BC480073B763 /* WaterRowerData-BLE */ = { 507 | isa = XCSwiftPackageProductDependency; 508 | productName = "WaterRowerData-BLE"; 509 | }; 510 | /* End XCSwiftPackageProductDependency section */ 511 | }; 512 | rootObject = 8C40E17D24BF555C004AE938 /* Project object */; 513 | } 514 | -------------------------------------------------------------------------------- /WaterRowerData-iOS.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXAggregateTarget section */ 10 | "WaterRowerData-iOS::WaterRowerData-iOSPackageTests::ProductTarget" /* WaterRowerData-iOSPackageTests */ = { 11 | isa = PBXAggregateTarget; 12 | buildConfigurationList = OBJ_102 /* Build configuration list for PBXAggregateTarget "WaterRowerData-iOSPackageTests" */; 13 | buildPhases = ( 14 | ); 15 | dependencies = ( 16 | OBJ_105 /* PBXTargetDependency */, 17 | ); 18 | name = "WaterRowerData-iOSPackageTests"; 19 | productName = "WaterRowerData-iOSPackageTests"; 20 | }; 21 | /* End PBXAggregateTarget section */ 22 | 23 | /* Begin PBXBuildFile section */ 24 | OBJ_100 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_6 /* Package.swift */; }; 25 | OBJ_56 /* FitnessMachineService.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_10 /* FitnessMachineService.swift */; }; 26 | OBJ_57 /* BitRequirement.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_13 /* BitRequirement.swift */; }; 27 | OBJ_58 /* Data+ReadFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_14 /* Data+ReadFormat.swift */; }; 28 | OBJ_59 /* Field.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_15 /* Field.swift */; }; 29 | OBJ_60 /* Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_16 /* Format.swift */; }; 30 | OBJ_61 /* Requirement.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_17 /* Requirement.swift */; }; 31 | OBJ_62 /* RowerDataAveragePaceField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_19 /* RowerDataAveragePaceField.swift */; }; 32 | OBJ_63 /* RowerDataAveragePowerField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_20 /* RowerDataAveragePowerField.swift */; }; 33 | OBJ_64 /* RowerDataAverageStrokeRateField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_21 /* RowerDataAverageStrokeRateField.swift */; }; 34 | OBJ_65 /* RowerDataElapsedTimeField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_22 /* RowerDataElapsedTimeField.swift */; }; 35 | OBJ_66 /* RowerDataEnergyPerHourField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_23 /* RowerDataEnergyPerHourField.swift */; }; 36 | OBJ_67 /* RowerDataEnergyPerMinuteField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_24 /* RowerDataEnergyPerMinuteField.swift */; }; 37 | OBJ_68 /* RowerDataFlagsField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_25 /* RowerDataFlagsField.swift */; }; 38 | OBJ_69 /* RowerDataHeartRateField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_26 /* RowerDataHeartRateField.swift */; }; 39 | OBJ_70 /* RowerDataInstantaneousPaceField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_27 /* RowerDataInstantaneousPaceField.swift */; }; 40 | OBJ_71 /* RowerDataInstantaneousPowerField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_28 /* RowerDataInstantaneousPowerField.swift */; }; 41 | OBJ_72 /* RowerDataMetabolicEquivalentField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_29 /* RowerDataMetabolicEquivalentField.swift */; }; 42 | OBJ_73 /* RowerDataRemainingTimeField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_30 /* RowerDataRemainingTimeField.swift */; }; 43 | OBJ_74 /* RowerDataResistanceLevelField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_31 /* RowerDataResistanceLevelField.swift */; }; 44 | OBJ_75 /* RowerDataStrokeCountField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_32 /* RowerDataStrokeCountField.swift */; }; 45 | OBJ_76 /* RowerDataStrokeRateField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_33 /* RowerDataStrokeRateField.swift */; }; 46 | OBJ_77 /* RowerDataTotalDistanceField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_34 /* RowerDataTotalDistanceField.swift */; }; 47 | OBJ_78 /* RowerDataTotalEnergyField.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_35 /* RowerDataTotalEnergyField.swift */; }; 48 | OBJ_79 /* RowerData.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_36 /* RowerData.swift */; }; 49 | OBJ_80 /* RowerDataCharacteristic.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_37 /* RowerDataCharacteristic.swift */; }; 50 | OBJ_87 /* CharacteristicData.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_41 /* CharacteristicData.swift */; }; 51 | OBJ_88 /* CharacteristicFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_42 /* CharacteristicFlags.swift */; }; 52 | OBJ_89 /* RowerDataCharacteristicFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_43 /* RowerDataCharacteristicFlags.swift */; }; 53 | OBJ_90 /* RowerDataCharacteristicTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_44 /* RowerDataCharacteristicTest.swift */; }; 54 | OBJ_93 /* WaterRowerData_BLE.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = "WaterRowerData-iOS::WaterRowerData-BLE::Product" /* WaterRowerData_BLE.framework */; }; 55 | /* End PBXBuildFile section */ 56 | 57 | /* Begin PBXContainerItemProxy section */ 58 | 8CA966A624C1BB230002B6CD /* PBXContainerItemProxy */ = { 59 | isa = PBXContainerItemProxy; 60 | containerPortal = OBJ_1 /* Project object */; 61 | proxyType = 1; 62 | remoteGlobalIDString = "WaterRowerData-iOS::WaterRowerData-BLE"; 63 | remoteInfo = "WaterRowerData-BLE"; 64 | }; 65 | 8CA966A724C1BB260002B6CD /* PBXContainerItemProxy */ = { 66 | isa = PBXContainerItemProxy; 67 | containerPortal = OBJ_1 /* Project object */; 68 | proxyType = 1; 69 | remoteGlobalIDString = "WaterRowerData-iOS::WaterRowerData-BLETests"; 70 | remoteInfo = "WaterRowerData-BLETests"; 71 | }; 72 | /* End PBXContainerItemProxy section */ 73 | 74 | /* Begin PBXFileReference section */ 75 | OBJ_10 /* FitnessMachineService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FitnessMachineService.swift; sourceTree = ""; }; 76 | OBJ_13 /* BitRequirement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitRequirement.swift; sourceTree = ""; }; 77 | OBJ_14 /* Data+ReadFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+ReadFormat.swift"; sourceTree = ""; }; 78 | OBJ_15 /* Field.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Field.swift; sourceTree = ""; }; 79 | OBJ_16 /* Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Format.swift; sourceTree = ""; }; 80 | OBJ_17 /* Requirement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Requirement.swift; sourceTree = ""; }; 81 | OBJ_19 /* RowerDataAveragePaceField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataAveragePaceField.swift; sourceTree = ""; }; 82 | OBJ_20 /* RowerDataAveragePowerField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataAveragePowerField.swift; sourceTree = ""; }; 83 | OBJ_21 /* RowerDataAverageStrokeRateField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataAverageStrokeRateField.swift; sourceTree = ""; }; 84 | OBJ_22 /* RowerDataElapsedTimeField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataElapsedTimeField.swift; sourceTree = ""; }; 85 | OBJ_23 /* RowerDataEnergyPerHourField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataEnergyPerHourField.swift; sourceTree = ""; }; 86 | OBJ_24 /* RowerDataEnergyPerMinuteField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataEnergyPerMinuteField.swift; sourceTree = ""; }; 87 | OBJ_25 /* RowerDataFlagsField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataFlagsField.swift; sourceTree = ""; }; 88 | OBJ_26 /* RowerDataHeartRateField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataHeartRateField.swift; sourceTree = ""; }; 89 | OBJ_27 /* RowerDataInstantaneousPaceField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataInstantaneousPaceField.swift; sourceTree = ""; }; 90 | OBJ_28 /* RowerDataInstantaneousPowerField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataInstantaneousPowerField.swift; sourceTree = ""; }; 91 | OBJ_29 /* RowerDataMetabolicEquivalentField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataMetabolicEquivalentField.swift; sourceTree = ""; }; 92 | OBJ_30 /* RowerDataRemainingTimeField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataRemainingTimeField.swift; sourceTree = ""; }; 93 | OBJ_31 /* RowerDataResistanceLevelField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataResistanceLevelField.swift; sourceTree = ""; }; 94 | OBJ_32 /* RowerDataStrokeCountField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataStrokeCountField.swift; sourceTree = ""; }; 95 | OBJ_33 /* RowerDataStrokeRateField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataStrokeRateField.swift; sourceTree = ""; }; 96 | OBJ_34 /* RowerDataTotalDistanceField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataTotalDistanceField.swift; sourceTree = ""; }; 97 | OBJ_35 /* RowerDataTotalEnergyField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataTotalEnergyField.swift; sourceTree = ""; }; 98 | OBJ_36 /* RowerData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerData.swift; sourceTree = ""; }; 99 | OBJ_37 /* RowerDataCharacteristic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataCharacteristic.swift; sourceTree = ""; }; 100 | OBJ_40 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 101 | OBJ_41 /* CharacteristicData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacteristicData.swift; sourceTree = ""; }; 102 | OBJ_42 /* CharacteristicFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacteristicFlags.swift; sourceTree = ""; }; 103 | OBJ_43 /* RowerDataCharacteristicFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataCharacteristicFlags.swift; sourceTree = ""; }; 104 | OBJ_44 /* RowerDataCharacteristicTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowerDataCharacteristicTest.swift; sourceTree = ""; }; 105 | OBJ_49 /* iOS Example */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "iOS Example"; sourceTree = SOURCE_ROOT; }; 106 | OBJ_50 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 107 | OBJ_6 /* Package.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; 108 | OBJ_9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 109 | "WaterRowerData-iOS::WaterRowerData-BLE::Product" /* WaterRowerData_BLE.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = WaterRowerData_BLE.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 110 | "WaterRowerData-iOS::WaterRowerData-BLETests::Product" /* WaterRowerData_BLETests.xctest */ = {isa = PBXFileReference; lastKnownFileType = file; name = WaterRowerData_BLETests.xctest; path = "WaterRowerData-BLETests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 111 | /* End PBXFileReference section */ 112 | 113 | /* Begin PBXFrameworksBuildPhase section */ 114 | OBJ_81 /* Frameworks */ = { 115 | isa = PBXFrameworksBuildPhase; 116 | buildActionMask = 0; 117 | files = ( 118 | ); 119 | runOnlyForDeploymentPostprocessing = 0; 120 | }; 121 | OBJ_92 /* Frameworks */ = { 122 | isa = PBXFrameworksBuildPhase; 123 | buildActionMask = 0; 124 | files = ( 125 | OBJ_93 /* WaterRowerData_BLE.framework in Frameworks */, 126 | ); 127 | runOnlyForDeploymentPostprocessing = 0; 128 | }; 129 | /* End PBXFrameworksBuildPhase section */ 130 | 131 | /* Begin PBXGroup section */ 132 | OBJ_11 /* Internal */ = { 133 | isa = PBXGroup; 134 | children = ( 135 | OBJ_12 /* GattSpecification */, 136 | OBJ_18 /* RowerDataSpecification */, 137 | ); 138 | path = Internal; 139 | sourceTree = ""; 140 | }; 141 | OBJ_12 /* GattSpecification */ = { 142 | isa = PBXGroup; 143 | children = ( 144 | OBJ_13 /* BitRequirement.swift */, 145 | OBJ_14 /* Data+ReadFormat.swift */, 146 | OBJ_15 /* Field.swift */, 147 | OBJ_16 /* Format.swift */, 148 | OBJ_17 /* Requirement.swift */, 149 | ); 150 | path = GattSpecification; 151 | sourceTree = ""; 152 | }; 153 | OBJ_18 /* RowerDataSpecification */ = { 154 | isa = PBXGroup; 155 | children = ( 156 | OBJ_19 /* RowerDataAveragePaceField.swift */, 157 | OBJ_20 /* RowerDataAveragePowerField.swift */, 158 | OBJ_21 /* RowerDataAverageStrokeRateField.swift */, 159 | OBJ_22 /* RowerDataElapsedTimeField.swift */, 160 | OBJ_23 /* RowerDataEnergyPerHourField.swift */, 161 | OBJ_24 /* RowerDataEnergyPerMinuteField.swift */, 162 | OBJ_25 /* RowerDataFlagsField.swift */, 163 | OBJ_26 /* RowerDataHeartRateField.swift */, 164 | OBJ_27 /* RowerDataInstantaneousPaceField.swift */, 165 | OBJ_28 /* RowerDataInstantaneousPowerField.swift */, 166 | OBJ_29 /* RowerDataMetabolicEquivalentField.swift */, 167 | OBJ_30 /* RowerDataRemainingTimeField.swift */, 168 | OBJ_31 /* RowerDataResistanceLevelField.swift */, 169 | OBJ_32 /* RowerDataStrokeCountField.swift */, 170 | OBJ_33 /* RowerDataStrokeRateField.swift */, 171 | OBJ_34 /* RowerDataTotalDistanceField.swift */, 172 | OBJ_35 /* RowerDataTotalEnergyField.swift */, 173 | ); 174 | path = RowerDataSpecification; 175 | sourceTree = ""; 176 | }; 177 | OBJ_38 /* Tests */ = { 178 | isa = PBXGroup; 179 | children = ( 180 | OBJ_39 /* WaterRowerData-BLETests */, 181 | ); 182 | name = Tests; 183 | sourceTree = SOURCE_ROOT; 184 | }; 185 | OBJ_39 /* WaterRowerData-BLETests */ = { 186 | isa = PBXGroup; 187 | children = ( 188 | OBJ_40 /* Info.plist */, 189 | OBJ_41 /* CharacteristicData.swift */, 190 | OBJ_42 /* CharacteristicFlags.swift */, 191 | OBJ_43 /* RowerDataCharacteristicFlags.swift */, 192 | OBJ_44 /* RowerDataCharacteristicTest.swift */, 193 | ); 194 | name = "WaterRowerData-BLETests"; 195 | path = "Tests/WaterRowerData-BLETests"; 196 | sourceTree = SOURCE_ROOT; 197 | }; 198 | OBJ_46 /* Products */ = { 199 | isa = PBXGroup; 200 | children = ( 201 | "WaterRowerData-iOS::WaterRowerData-BLE::Product" /* WaterRowerData_BLE.framework */, 202 | "WaterRowerData-iOS::WaterRowerData-BLETests::Product" /* WaterRowerData_BLETests.xctest */, 203 | ); 204 | name = Products; 205 | sourceTree = BUILT_PRODUCTS_DIR; 206 | }; 207 | OBJ_5 /* */ = { 208 | isa = PBXGroup; 209 | children = ( 210 | OBJ_6 /* Package.swift */, 211 | OBJ_7 /* Sources */, 212 | OBJ_38 /* Tests */, 213 | OBJ_46 /* Products */, 214 | OBJ_49 /* iOS Example */, 215 | OBJ_50 /* README.md */, 216 | ); 217 | name = ""; 218 | sourceTree = ""; 219 | }; 220 | OBJ_7 /* Sources */ = { 221 | isa = PBXGroup; 222 | children = ( 223 | OBJ_8 /* WaterRowerData-BLE */, 224 | ); 225 | name = Sources; 226 | sourceTree = SOURCE_ROOT; 227 | }; 228 | OBJ_8 /* WaterRowerData-BLE */ = { 229 | isa = PBXGroup; 230 | children = ( 231 | OBJ_9 /* Info.plist */, 232 | OBJ_10 /* FitnessMachineService.swift */, 233 | OBJ_11 /* Internal */, 234 | OBJ_36 /* RowerData.swift */, 235 | OBJ_37 /* RowerDataCharacteristic.swift */, 236 | ); 237 | name = "WaterRowerData-BLE"; 238 | path = "Sources/WaterRowerData-BLE"; 239 | sourceTree = SOURCE_ROOT; 240 | }; 241 | /* End PBXGroup section */ 242 | 243 | /* Begin PBXNativeTarget section */ 244 | "WaterRowerData-iOS::SwiftPMPackageDescription" /* WaterRowerData-iOSPackageDescription */ = { 245 | isa = PBXNativeTarget; 246 | buildConfigurationList = OBJ_96 /* Build configuration list for PBXNativeTarget "WaterRowerData-iOSPackageDescription" */; 247 | buildPhases = ( 248 | OBJ_99 /* Sources */, 249 | ); 250 | buildRules = ( 251 | ); 252 | dependencies = ( 253 | ); 254 | name = "WaterRowerData-iOSPackageDescription"; 255 | productName = "WaterRowerData-iOSPackageDescription"; 256 | productType = "com.apple.product-type.framework"; 257 | }; 258 | "WaterRowerData-iOS::WaterRowerData-BLE" /* WaterRowerData-BLE */ = { 259 | isa = PBXNativeTarget; 260 | buildConfigurationList = OBJ_52 /* Build configuration list for PBXNativeTarget "WaterRowerData-BLE" */; 261 | buildPhases = ( 262 | OBJ_55 /* Sources */, 263 | OBJ_81 /* Frameworks */, 264 | ); 265 | buildRules = ( 266 | ); 267 | dependencies = ( 268 | ); 269 | name = "WaterRowerData-BLE"; 270 | productName = WaterRowerData_BLE; 271 | productReference = "WaterRowerData-iOS::WaterRowerData-BLE::Product" /* WaterRowerData_BLE.framework */; 272 | productType = "com.apple.product-type.framework"; 273 | }; 274 | "WaterRowerData-iOS::WaterRowerData-BLETests" /* WaterRowerData-BLETests */ = { 275 | isa = PBXNativeTarget; 276 | buildConfigurationList = OBJ_83 /* Build configuration list for PBXNativeTarget "WaterRowerData-BLETests" */; 277 | buildPhases = ( 278 | OBJ_86 /* Sources */, 279 | OBJ_92 /* Frameworks */, 280 | ); 281 | buildRules = ( 282 | ); 283 | dependencies = ( 284 | OBJ_94 /* PBXTargetDependency */, 285 | ); 286 | name = "WaterRowerData-BLETests"; 287 | productName = WaterRowerData_BLETests; 288 | productReference = "WaterRowerData-iOS::WaterRowerData-BLETests::Product" /* WaterRowerData_BLETests.xctest */; 289 | productType = "com.apple.product-type.bundle.unit-test"; 290 | }; 291 | /* End PBXNativeTarget section */ 292 | 293 | /* Begin PBXProject section */ 294 | OBJ_1 /* Project object */ = { 295 | isa = PBXProject; 296 | attributes = { 297 | LastSwiftMigration = 9999; 298 | LastUpgradeCheck = 9999; 299 | }; 300 | buildConfigurationList = OBJ_2 /* Build configuration list for PBXProject "WaterRowerData-iOS" */; 301 | compatibilityVersion = "Xcode 3.2"; 302 | developmentRegion = en; 303 | hasScannedForEncodings = 0; 304 | knownRegions = ( 305 | en, 306 | ); 307 | mainGroup = OBJ_5 /* */; 308 | productRefGroup = OBJ_46 /* Products */; 309 | projectDirPath = ""; 310 | projectRoot = ""; 311 | targets = ( 312 | "WaterRowerData-iOS::WaterRowerData-BLE" /* WaterRowerData-BLE */, 313 | "WaterRowerData-iOS::WaterRowerData-BLETests" /* WaterRowerData-BLETests */, 314 | "WaterRowerData-iOS::SwiftPMPackageDescription" /* WaterRowerData-iOSPackageDescription */, 315 | "WaterRowerData-iOS::WaterRowerData-iOSPackageTests::ProductTarget" /* WaterRowerData-iOSPackageTests */, 316 | ); 317 | }; 318 | /* End PBXProject section */ 319 | 320 | /* Begin PBXSourcesBuildPhase section */ 321 | OBJ_55 /* Sources */ = { 322 | isa = PBXSourcesBuildPhase; 323 | buildActionMask = 0; 324 | files = ( 325 | OBJ_56 /* FitnessMachineService.swift in Sources */, 326 | OBJ_57 /* BitRequirement.swift in Sources */, 327 | OBJ_58 /* Data+ReadFormat.swift in Sources */, 328 | OBJ_59 /* Field.swift in Sources */, 329 | OBJ_60 /* Format.swift in Sources */, 330 | OBJ_61 /* Requirement.swift in Sources */, 331 | OBJ_62 /* RowerDataAveragePaceField.swift in Sources */, 332 | OBJ_63 /* RowerDataAveragePowerField.swift in Sources */, 333 | OBJ_64 /* RowerDataAverageStrokeRateField.swift in Sources */, 334 | OBJ_65 /* RowerDataElapsedTimeField.swift in Sources */, 335 | OBJ_66 /* RowerDataEnergyPerHourField.swift in Sources */, 336 | OBJ_67 /* RowerDataEnergyPerMinuteField.swift in Sources */, 337 | OBJ_68 /* RowerDataFlagsField.swift in Sources */, 338 | OBJ_69 /* RowerDataHeartRateField.swift in Sources */, 339 | OBJ_70 /* RowerDataInstantaneousPaceField.swift in Sources */, 340 | OBJ_71 /* RowerDataInstantaneousPowerField.swift in Sources */, 341 | OBJ_72 /* RowerDataMetabolicEquivalentField.swift in Sources */, 342 | OBJ_73 /* RowerDataRemainingTimeField.swift in Sources */, 343 | OBJ_74 /* RowerDataResistanceLevelField.swift in Sources */, 344 | OBJ_75 /* RowerDataStrokeCountField.swift in Sources */, 345 | OBJ_76 /* RowerDataStrokeRateField.swift in Sources */, 346 | OBJ_77 /* RowerDataTotalDistanceField.swift in Sources */, 347 | OBJ_78 /* RowerDataTotalEnergyField.swift in Sources */, 348 | OBJ_79 /* RowerData.swift in Sources */, 349 | OBJ_80 /* RowerDataCharacteristic.swift in Sources */, 350 | ); 351 | runOnlyForDeploymentPostprocessing = 0; 352 | }; 353 | OBJ_86 /* Sources */ = { 354 | isa = PBXSourcesBuildPhase; 355 | buildActionMask = 0; 356 | files = ( 357 | OBJ_87 /* CharacteristicData.swift in Sources */, 358 | OBJ_88 /* CharacteristicFlags.swift in Sources */, 359 | OBJ_89 /* RowerDataCharacteristicFlags.swift in Sources */, 360 | OBJ_90 /* RowerDataCharacteristicTest.swift in Sources */, 361 | ); 362 | runOnlyForDeploymentPostprocessing = 0; 363 | }; 364 | OBJ_99 /* Sources */ = { 365 | isa = PBXSourcesBuildPhase; 366 | buildActionMask = 0; 367 | files = ( 368 | OBJ_100 /* Package.swift in Sources */, 369 | ); 370 | runOnlyForDeploymentPostprocessing = 0; 371 | }; 372 | /* End PBXSourcesBuildPhase section */ 373 | 374 | /* Begin PBXTargetDependency section */ 375 | OBJ_105 /* PBXTargetDependency */ = { 376 | isa = PBXTargetDependency; 377 | target = "WaterRowerData-iOS::WaterRowerData-BLETests" /* WaterRowerData-BLETests */; 378 | targetProxy = 8CA966A724C1BB260002B6CD /* PBXContainerItemProxy */; 379 | }; 380 | OBJ_94 /* PBXTargetDependency */ = { 381 | isa = PBXTargetDependency; 382 | target = "WaterRowerData-iOS::WaterRowerData-BLE" /* WaterRowerData-BLE */; 383 | targetProxy = 8CA966A624C1BB230002B6CD /* PBXContainerItemProxy */; 384 | }; 385 | /* End PBXTargetDependency section */ 386 | 387 | /* Begin XCBuildConfiguration section */ 388 | OBJ_103 /* Debug */ = { 389 | isa = XCBuildConfiguration; 390 | buildSettings = { 391 | }; 392 | name = Debug; 393 | }; 394 | OBJ_104 /* Release */ = { 395 | isa = XCBuildConfiguration; 396 | buildSettings = { 397 | }; 398 | name = Release; 399 | }; 400 | OBJ_3 /* Debug */ = { 401 | isa = XCBuildConfiguration; 402 | buildSettings = { 403 | CLANG_ENABLE_OBJC_ARC = YES; 404 | COMBINE_HIDPI_IMAGES = YES; 405 | COPY_PHASE_STRIP = NO; 406 | DEBUG_INFORMATION_FORMAT = dwarf; 407 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 408 | ENABLE_NS_ASSERTIONS = YES; 409 | GCC_OPTIMIZATION_LEVEL = 0; 410 | GCC_PREPROCESSOR_DEFINITIONS = ( 411 | "$(inherited)", 412 | "SWIFT_PACKAGE=1", 413 | "DEBUG=1", 414 | ); 415 | MACOSX_DEPLOYMENT_TARGET = 10.10; 416 | ONLY_ACTIVE_ARCH = YES; 417 | OTHER_SWIFT_FLAGS = "$(inherited) -DXcode"; 418 | PRODUCT_NAME = "$(TARGET_NAME)"; 419 | SDKROOT = macosx; 420 | SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator watchos watchsimulator"; 421 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) SWIFT_PACKAGE DEBUG"; 422 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 423 | USE_HEADERMAP = NO; 424 | }; 425 | name = Debug; 426 | }; 427 | OBJ_4 /* Release */ = { 428 | isa = XCBuildConfiguration; 429 | buildSettings = { 430 | CLANG_ENABLE_OBJC_ARC = YES; 431 | COMBINE_HIDPI_IMAGES = YES; 432 | COPY_PHASE_STRIP = YES; 433 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 434 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 435 | GCC_OPTIMIZATION_LEVEL = s; 436 | GCC_PREPROCESSOR_DEFINITIONS = ( 437 | "$(inherited)", 438 | "SWIFT_PACKAGE=1", 439 | ); 440 | MACOSX_DEPLOYMENT_TARGET = 10.10; 441 | OTHER_SWIFT_FLAGS = "$(inherited) -DXcode"; 442 | PRODUCT_NAME = "$(TARGET_NAME)"; 443 | SDKROOT = macosx; 444 | SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator watchos watchsimulator"; 445 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) SWIFT_PACKAGE"; 446 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 447 | USE_HEADERMAP = NO; 448 | }; 449 | name = Release; 450 | }; 451 | OBJ_53 /* Debug */ = { 452 | isa = XCBuildConfiguration; 453 | buildSettings = { 454 | ENABLE_TESTABILITY = YES; 455 | FRAMEWORK_SEARCH_PATHS = ( 456 | "$(inherited)", 457 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 458 | ); 459 | HEADER_SEARCH_PATHS = "$(inherited)"; 460 | INFOPLIST_FILE = "WaterRowerData-iOS.xcodeproj/WaterRowerData_BLE_Info.plist"; 461 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 462 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) $(TOOLCHAIN_DIR)/usr/lib/swift/macosx"; 463 | MACOSX_DEPLOYMENT_TARGET = 10.10; 464 | OTHER_CFLAGS = "$(inherited)"; 465 | OTHER_LDFLAGS = "$(inherited)"; 466 | OTHER_SWIFT_FLAGS = "$(inherited)"; 467 | PRODUCT_BUNDLE_IDENTIFIER = "WaterRowerData-BLE"; 468 | PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; 469 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 470 | SKIP_INSTALL = YES; 471 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; 472 | SWIFT_VERSION = 5.0; 473 | TARGET_NAME = "WaterRowerData-BLE"; 474 | TVOS_DEPLOYMENT_TARGET = 9.0; 475 | WATCHOS_DEPLOYMENT_TARGET = 2.0; 476 | }; 477 | name = Debug; 478 | }; 479 | OBJ_54 /* Release */ = { 480 | isa = XCBuildConfiguration; 481 | buildSettings = { 482 | ENABLE_TESTABILITY = YES; 483 | FRAMEWORK_SEARCH_PATHS = ( 484 | "$(inherited)", 485 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 486 | ); 487 | HEADER_SEARCH_PATHS = "$(inherited)"; 488 | INFOPLIST_FILE = "WaterRowerData-iOS.xcodeproj/WaterRowerData_BLE_Info.plist"; 489 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 490 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) $(TOOLCHAIN_DIR)/usr/lib/swift/macosx"; 491 | MACOSX_DEPLOYMENT_TARGET = 10.10; 492 | OTHER_CFLAGS = "$(inherited)"; 493 | OTHER_LDFLAGS = "$(inherited)"; 494 | OTHER_SWIFT_FLAGS = "$(inherited)"; 495 | PRODUCT_BUNDLE_IDENTIFIER = "WaterRowerData-BLE"; 496 | PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; 497 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 498 | SKIP_INSTALL = YES; 499 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; 500 | SWIFT_VERSION = 5.0; 501 | TARGET_NAME = "WaterRowerData-BLE"; 502 | TVOS_DEPLOYMENT_TARGET = 9.0; 503 | WATCHOS_DEPLOYMENT_TARGET = 2.0; 504 | }; 505 | name = Release; 506 | }; 507 | OBJ_84 /* Debug */ = { 508 | isa = XCBuildConfiguration; 509 | buildSettings = { 510 | CLANG_ENABLE_MODULES = YES; 511 | EMBEDDED_CONTENT_CONTAINS_SWIFT = YES; 512 | FRAMEWORK_SEARCH_PATHS = ( 513 | "$(inherited)", 514 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 515 | ); 516 | HEADER_SEARCH_PATHS = "$(inherited)"; 517 | INFOPLIST_FILE = "WaterRowerData-iOS.xcodeproj/WaterRowerData_BLETests_Info.plist"; 518 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 519 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @loader_path/../Frameworks @loader_path/Frameworks"; 520 | MACOSX_DEPLOYMENT_TARGET = 10.10; 521 | OTHER_CFLAGS = "$(inherited)"; 522 | OTHER_LDFLAGS = "$(inherited)"; 523 | OTHER_SWIFT_FLAGS = "$(inherited)"; 524 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; 525 | SWIFT_VERSION = 5.0; 526 | TARGET_NAME = "WaterRowerData-BLETests"; 527 | TVOS_DEPLOYMENT_TARGET = 9.0; 528 | WATCHOS_DEPLOYMENT_TARGET = 2.0; 529 | }; 530 | name = Debug; 531 | }; 532 | OBJ_85 /* Release */ = { 533 | isa = XCBuildConfiguration; 534 | buildSettings = { 535 | CLANG_ENABLE_MODULES = YES; 536 | EMBEDDED_CONTENT_CONTAINS_SWIFT = YES; 537 | FRAMEWORK_SEARCH_PATHS = ( 538 | "$(inherited)", 539 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 540 | ); 541 | HEADER_SEARCH_PATHS = "$(inherited)"; 542 | INFOPLIST_FILE = "WaterRowerData-iOS.xcodeproj/WaterRowerData_BLETests_Info.plist"; 543 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 544 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @loader_path/../Frameworks @loader_path/Frameworks"; 545 | MACOSX_DEPLOYMENT_TARGET = 10.10; 546 | OTHER_CFLAGS = "$(inherited)"; 547 | OTHER_LDFLAGS = "$(inherited)"; 548 | OTHER_SWIFT_FLAGS = "$(inherited)"; 549 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; 550 | SWIFT_VERSION = 5.0; 551 | TARGET_NAME = "WaterRowerData-BLETests"; 552 | TVOS_DEPLOYMENT_TARGET = 9.0; 553 | WATCHOS_DEPLOYMENT_TARGET = 2.0; 554 | }; 555 | name = Release; 556 | }; 557 | OBJ_97 /* Debug */ = { 558 | isa = XCBuildConfiguration; 559 | buildSettings = { 560 | LD = /usr/bin/true; 561 | OTHER_SWIFT_FLAGS = "-swift-version 5 -I $(TOOLCHAIN_DIR)/usr/lib/swift/pm/4_2 -target x86_64-apple-macosx10.10 -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk -package-description-version 5.2.0"; 562 | SWIFT_VERSION = 5.0; 563 | }; 564 | name = Debug; 565 | }; 566 | OBJ_98 /* Release */ = { 567 | isa = XCBuildConfiguration; 568 | buildSettings = { 569 | LD = /usr/bin/true; 570 | OTHER_SWIFT_FLAGS = "-swift-version 5 -I $(TOOLCHAIN_DIR)/usr/lib/swift/pm/4_2 -target x86_64-apple-macosx10.10 -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk -package-description-version 5.2.0"; 571 | SWIFT_VERSION = 5.0; 572 | }; 573 | name = Release; 574 | }; 575 | /* End XCBuildConfiguration section */ 576 | 577 | /* Begin XCConfigurationList section */ 578 | OBJ_102 /* Build configuration list for PBXAggregateTarget "WaterRowerData-iOSPackageTests" */ = { 579 | isa = XCConfigurationList; 580 | buildConfigurations = ( 581 | OBJ_103 /* Debug */, 582 | OBJ_104 /* Release */, 583 | ); 584 | defaultConfigurationIsVisible = 0; 585 | defaultConfigurationName = Release; 586 | }; 587 | OBJ_2 /* Build configuration list for PBXProject "WaterRowerData-iOS" */ = { 588 | isa = XCConfigurationList; 589 | buildConfigurations = ( 590 | OBJ_3 /* Debug */, 591 | OBJ_4 /* Release */, 592 | ); 593 | defaultConfigurationIsVisible = 0; 594 | defaultConfigurationName = Release; 595 | }; 596 | OBJ_52 /* Build configuration list for PBXNativeTarget "WaterRowerData-BLE" */ = { 597 | isa = XCConfigurationList; 598 | buildConfigurations = ( 599 | OBJ_53 /* Debug */, 600 | OBJ_54 /* Release */, 601 | ); 602 | defaultConfigurationIsVisible = 0; 603 | defaultConfigurationName = Release; 604 | }; 605 | OBJ_83 /* Build configuration list for PBXNativeTarget "WaterRowerData-BLETests" */ = { 606 | isa = XCConfigurationList; 607 | buildConfigurations = ( 608 | OBJ_84 /* Debug */, 609 | OBJ_85 /* Release */, 610 | ); 611 | defaultConfigurationIsVisible = 0; 612 | defaultConfigurationName = Release; 613 | }; 614 | OBJ_96 /* Build configuration list for PBXNativeTarget "WaterRowerData-iOSPackageDescription" */ = { 615 | isa = XCConfigurationList; 616 | buildConfigurations = ( 617 | OBJ_97 /* Debug */, 618 | OBJ_98 /* Release */, 619 | ); 620 | defaultConfigurationIsVisible = 0; 621 | defaultConfigurationName = Release; 622 | }; 623 | /* End XCConfigurationList section */ 624 | }; 625 | rootObject = OBJ_1 /* Project object */; 626 | } 627 | -------------------------------------------------------------------------------- /Tests/WaterRowerData-BLETests/RowerDataCharacteristicTest.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import WaterRowerData_BLE 4 | 5 | class RowerDataCharacteristicTest: XCTestCase { 6 | 7 | // MARK: Stroke Rate 8 | 9 | func test_strokeRate_notPresent_resultsIn_nilValue() { 10 | /* Given */ 11 | let flags = RowerDataCharacteristicFlags.create(moreDataPresent: false) 12 | let data = CharacteristicData.create(flags: flags) 13 | 14 | /* When */ 15 | let result = RowerDataCharacteristic.decode(data: data) 16 | 17 | /* Then */ 18 | XCTAssertNil(result.strokeRate) 19 | } 20 | 21 | func test_strokeRate_present_resultsIn_uint8Value_withBinaryExponentMinusOne() { 22 | /* Given */ 23 | let flags = RowerDataCharacteristicFlags.create(moreDataPresent: true) 24 | let data = CharacteristicData.create( 25 | flags: flags, 26 | values: 7, // Stroke rate of 3.5 27 | 0, // Stroke count value 28 | 0 29 | ) 30 | 31 | /* When */ 32 | let result = RowerDataCharacteristic.decode(data: data) 33 | 34 | /* Then */ 35 | XCTAssertEqual(result.strokeRate, 3.5) 36 | } 37 | 38 | // MARK: Stroke Count 39 | 40 | func test_strokeCount_notPresent_resultsIn_nilValue() { 41 | /* Given */ 42 | let flags = RowerDataCharacteristicFlags.create(moreDataPresent: false) 43 | let data = CharacteristicData.create(flags: flags) 44 | 45 | /* When */ 46 | let result = RowerDataCharacteristic.decode(data: data) 47 | 48 | /* Then */ 49 | XCTAssertNil(result.strokeCount) 50 | } 51 | 52 | func test_strokeCount_present_resultsIn_uint16Value_lowValue() { 53 | /* Given */ 54 | let flags = RowerDataCharacteristicFlags.create(moreDataPresent: true) 55 | let data = CharacteristicData.create( 56 | flags: flags, 57 | values: 0, // Stroke Rate 58 | 1, // Stroke count of 1 59 | 0 60 | ) 61 | 62 | /* When */ 63 | let result = RowerDataCharacteristic.decode(data: data) 64 | 65 | /* Then */ 66 | XCTAssertEqual(result.strokeCount, 1) 67 | } 68 | 69 | func test_strokeCount_present_resultsIn_uint16Value_highValue() { 70 | /* Given */ 71 | let flags = RowerDataCharacteristicFlags.create(moreDataPresent: true) 72 | let data = CharacteristicData.create( 73 | flags: flags, 74 | values: 0, // Stroke Rate 75 | 1, // Stroke count of 1 + 256 76 | 1 77 | ) 78 | 79 | /* When */ 80 | let result = RowerDataCharacteristic.decode(data: data) 81 | 82 | /* Then */ 83 | XCTAssertEqual(result.strokeCount, 257) 84 | } 85 | 86 | // MARK: Average Stroke Rate 87 | 88 | func test_averageStrokeRate_notPresent_resultsIn_nilValue() { 89 | /* Given */ 90 | let flags = RowerDataCharacteristicFlags.create(averageStrokeRatePresent: false) 91 | let data = CharacteristicData.create(flags: flags) 92 | 93 | /* When */ 94 | let result = RowerDataCharacteristic.decode(data: data) 95 | 96 | /* Then */ 97 | XCTAssertNil(result.averageStrokeRate) 98 | } 99 | 100 | func test_averageStrokeRate_present_resultsIn_uint8Value_withBinaryExponentMinusOne() { 101 | /* Given */ 102 | let flags = RowerDataCharacteristicFlags.create(averageStrokeRatePresent: true) 103 | let data = CharacteristicData.create(flags: flags, values: 7) 104 | 105 | /* When */ 106 | let result = RowerDataCharacteristic.decode(data: data) 107 | 108 | /* Then */ 109 | XCTAssertEqual(result.averageStrokeRate, 3.5) 110 | } 111 | 112 | // MARK: Total Distance 113 | 114 | func test_totalDistance_notPresent_resultsIn_nilValue() { 115 | /* Given */ 116 | let flags = RowerDataCharacteristicFlags.create(totalDistancePresent: false) 117 | let data = CharacteristicData.create(flags: flags) 118 | 119 | /* When */ 120 | let result = RowerDataCharacteristic.decode(data: data) 121 | 122 | /* Then */ 123 | XCTAssertNil(result.totalDistanceMeters) 124 | } 125 | 126 | func test_totalDistance_present_resultsIn_uint24Value_forLowValue() { 127 | /* Given */ 128 | let flags = RowerDataCharacteristicFlags.create(totalDistancePresent: true) 129 | let data = CharacteristicData.create(flags: flags, values: 1, 0, 0) 130 | 131 | /* When */ 132 | let result = RowerDataCharacteristic.decode(data: data) 133 | 134 | /* Then */ 135 | XCTAssertEqual(result.totalDistanceMeters, 1) 136 | } 137 | 138 | func test_totalDistance_present_resultsIn_uint24Value_forMediumValue() { 139 | /* Given */ 140 | let flags = RowerDataCharacteristicFlags.create(totalDistancePresent: true) 141 | let data = CharacteristicData.create(flags: flags, values: 1, 2, 0) // 1 + 512 142 | 143 | /* When */ 144 | let result = RowerDataCharacteristic.decode(data: data) 145 | 146 | /* Then */ 147 | XCTAssertEqual(result.totalDistanceMeters, 513) 148 | } 149 | 150 | func test_totalDistance_present_resultsIn_uint24Value_forHighValue() { 151 | /* Given */ 152 | let flags = RowerDataCharacteristicFlags.create(totalDistancePresent: true) 153 | let data = CharacteristicData.create(flags: flags, values: 1, 2, 4) // 1 + 512 + 262144 154 | 155 | /* When */ 156 | let result = RowerDataCharacteristic.decode(data: data) 157 | 158 | /* Then */ 159 | XCTAssertEqual(result.totalDistanceMeters, 262657) 160 | } 161 | 162 | // MARK: Instantaneous Pace 163 | 164 | func test_instantaneousPace_notPresent_resultsIn_nilValue() { 165 | /* Given */ 166 | let flags = RowerDataCharacteristicFlags.create(instantaneousPacePresent: false) 167 | let data = CharacteristicData.create(flags: flags) 168 | 169 | /* When */ 170 | let result = RowerDataCharacteristic.decode(data: data) 171 | 172 | /* Then */ 173 | XCTAssertNil(result.instantaneousPaceSeconds) 174 | } 175 | 176 | func test_instantaneousPace_notPresent_resultsIn_uint16Value_forLowValue() { 177 | /* Given */ 178 | let flags = RowerDataCharacteristicFlags.create(instantaneousPacePresent: true) 179 | let data = CharacteristicData.create(flags: flags, values: 1, 0) 180 | 181 | /* When */ 182 | let result = RowerDataCharacteristic.decode(data: data) 183 | 184 | /* Then */ 185 | XCTAssertEqual(result.instantaneousPaceSeconds, 1) 186 | } 187 | 188 | func test_instantaneousPace_notPresent_resultsIn_uint16Value_forHighValue() { 189 | /* Given */ 190 | let flags = RowerDataCharacteristicFlags.create(instantaneousPacePresent: true) 191 | let data = CharacteristicData.create(flags: flags, values: 1, 2) // 1 + 512 192 | 193 | /* When */ 194 | let result = RowerDataCharacteristic.decode(data: data) 195 | 196 | /* Then */ 197 | XCTAssertEqual(result.instantaneousPaceSeconds, 513) 198 | } 199 | 200 | // MARK: Average Pace 201 | 202 | func test_averagePace_notPresent_resultsIn_nilValue() { 203 | /* Given */ 204 | let flags = RowerDataCharacteristicFlags.create(averagePacePresent: false) 205 | let data = CharacteristicData.create(flags: flags) 206 | 207 | /* When */ 208 | let result = RowerDataCharacteristic.decode(data: data) 209 | 210 | /* Then */ 211 | XCTAssertNil(result.averagePaceSeconds) 212 | } 213 | 214 | func test_averagePace_present_resultsIn_uint16Value_forLowValue() { 215 | /* Given */ 216 | let flags = RowerDataCharacteristicFlags.create(averagePacePresent: true) 217 | let data = CharacteristicData.create(flags: flags, values: 1, 0) 218 | 219 | /* When */ 220 | let result = RowerDataCharacteristic.decode(data: data) 221 | 222 | /* Then */ 223 | XCTAssertEqual(result.averagePaceSeconds, 1) 224 | } 225 | 226 | func test_averagePace_present_resultsIn_uint16Value_forHighValue() { 227 | /* Given */ 228 | let flags = RowerDataCharacteristicFlags.create(averagePacePresent: true) 229 | let data = CharacteristicData.create(flags: flags, values: 1, 2) // 1 + 512 230 | 231 | /* When */ 232 | let result = RowerDataCharacteristic.decode(data: data) 233 | 234 | /* Then */ 235 | XCTAssertEqual(result.averagePaceSeconds, 513) 236 | } 237 | 238 | // MARK: Instantaneous Power 239 | 240 | func test_instantaneousPower_notPresent_resultsIn_nilValue() { 241 | /* Given */ 242 | let flags = RowerDataCharacteristicFlags.create(instantaneousPowerPresent: false) 243 | let data = CharacteristicData.create(flags: flags) 244 | 245 | /* When */ 246 | let result = RowerDataCharacteristic.decode(data: data) 247 | 248 | /* Then */ 249 | XCTAssertNil(result.instantaneousPowerWatts) 250 | } 251 | 252 | func test_instantaneousPower_present_resultsIn_sint16Value_forLowValue() { 253 | /* Given */ 254 | let flags = RowerDataCharacteristicFlags.create(instantaneousPowerPresent: true) 255 | let data = CharacteristicData.create(flags: flags, values: 1, 0) 256 | 257 | /* When */ 258 | let result = RowerDataCharacteristic.decode(data: data) 259 | 260 | /* Then */ 261 | XCTAssertEqual(result.instantaneousPowerWatts, 1) 262 | } 263 | 264 | func test_instantaneousPower_present_resultsIn_sint16Value_forHighValue() { 265 | /* Given */ 266 | let flags = RowerDataCharacteristicFlags.create(instantaneousPowerPresent: true) 267 | let data = CharacteristicData.create(flags: flags, values: 1, 2) // 1 + 512 268 | 269 | /* When */ 270 | let result = RowerDataCharacteristic.decode(data: data) 271 | 272 | /* Then */ 273 | XCTAssertEqual(result.instantaneousPowerWatts, 513) 274 | } 275 | 276 | func test_instantaneousPower_present_resultsIn_sint16Value_forLowNegativeValue() { 277 | /* Given */ 278 | let flags = RowerDataCharacteristicFlags.create(instantaneousPowerPresent: true) 279 | let data = CharacteristicData.create(flags: flags, values: 0b11111111, 0b11111111) 280 | 281 | /* When */ 282 | let result = RowerDataCharacteristic.decode(data: data) 283 | 284 | /* Then */ 285 | XCTAssertEqual(result.instantaneousPowerWatts, -1) 286 | } 287 | 288 | func test_instantaneousPower_present_resultsIn_sint16Value_forHighNegativeValue() { 289 | /* Given */ 290 | let flags = RowerDataCharacteristicFlags.create(instantaneousPowerPresent: true) 291 | let data = CharacteristicData.create(flags: flags, values: 0b11111111, 0b11111110) 292 | 293 | /* When */ 294 | let result = RowerDataCharacteristic.decode(data: data) 295 | 296 | /* Then */ 297 | XCTAssertEqual(result.instantaneousPowerWatts, -257) 298 | } 299 | 300 | // MARK: Average Power 301 | 302 | func test_averagePower_notPresent_resultsIn_nilValue() { 303 | /* Given */ 304 | let flags = RowerDataCharacteristicFlags.create(averagePowerPresent: false) 305 | let data = CharacteristicData.create(flags: flags) 306 | 307 | /* When */ 308 | let result = RowerDataCharacteristic.decode(data: data) 309 | 310 | /* Then */ 311 | XCTAssertNil(result.averagePowerWatts) 312 | } 313 | 314 | func test_averagePower_present_resultsIn_sint16Value_forLowValue() { 315 | /* Given */ 316 | let flags = RowerDataCharacteristicFlags.create(averagePowerPresent: true) 317 | let data = CharacteristicData.create(flags: flags, values: 1, 0) 318 | 319 | /* When */ 320 | let result = RowerDataCharacteristic.decode(data: data) 321 | 322 | /* Then */ 323 | XCTAssertEqual(result.averagePowerWatts, 1) 324 | } 325 | 326 | func test_averagePower_present_resultsIn_sint16Value_forHighValue() { 327 | /* Given */ 328 | let flags = RowerDataCharacteristicFlags.create(averagePowerPresent: true) 329 | let data = CharacteristicData.create(flags: flags, values: 1, 2) // 1 + 512 330 | 331 | /* When */ 332 | let result = RowerDataCharacteristic.decode(data: data) 333 | 334 | /* Then */ 335 | XCTAssertEqual(result.averagePowerWatts, 513) 336 | } 337 | 338 | func test_averagePower_present_resultsIn_sint16Value_forLowNegativeValue() { 339 | /* Given */ 340 | let flags = RowerDataCharacteristicFlags.create(averagePowerPresent: true) 341 | let data = CharacteristicData.create(flags: flags, values: 0b11111111, 0b11111111) 342 | 343 | /* When */ 344 | let result = RowerDataCharacteristic.decode(data: data) 345 | 346 | /* Then */ 347 | XCTAssertEqual(result.averagePowerWatts, -1) 348 | } 349 | 350 | func test_averagePower_present_resultsIn_sint16Value_forHighNegativeValue() { 351 | /* Given */ 352 | let flags = RowerDataCharacteristicFlags.create(averagePowerPresent: true) 353 | let data = CharacteristicData.create(flags: flags, values: 0b11111111, 0b11111110) 354 | 355 | /* When */ 356 | let result = RowerDataCharacteristic.decode(data: data) 357 | 358 | /* Then */ 359 | XCTAssertEqual(result.averagePowerWatts, -257) 360 | } 361 | 362 | // MARK: Resistance level 363 | 364 | func test_resistanceLevel_notPresent_resultsIn_nilValue() { 365 | /* Given */ 366 | let flags = RowerDataCharacteristicFlags.create(resistanceLevelPresent: false) 367 | let data = CharacteristicData.create(flags: flags) 368 | 369 | /* When */ 370 | let result = RowerDataCharacteristic.decode(data: data) 371 | 372 | /* Then */ 373 | XCTAssertNil(result.resistanceLevel) 374 | } 375 | 376 | func test_resistanceLevel_present_resultsIn_sint16Value_forLowValue() { 377 | /* Given */ 378 | let flags = RowerDataCharacteristicFlags.create(resistanceLevelPresent: true) 379 | let data = CharacteristicData.create(flags: flags, values: 1, 0) 380 | 381 | /* When */ 382 | let result = RowerDataCharacteristic.decode(data: data) 383 | 384 | /* Then */ 385 | XCTAssertEqual(result.resistanceLevel, 1) 386 | } 387 | 388 | func test_resistanceLevel_present_resultsIn_sint16Value_forHighValue() { 389 | /* Given */ 390 | let flags = RowerDataCharacteristicFlags.create(resistanceLevelPresent: true) 391 | let data = CharacteristicData.create(flags: flags, values: 1, 2) // 1 + 512 392 | 393 | /* When */ 394 | let result = RowerDataCharacteristic.decode(data: data) 395 | 396 | /* Then */ 397 | XCTAssertEqual(result.resistanceLevel, 513) 398 | } 399 | 400 | func test_resistanceLevel_present_resultsIn_sint16Value_forLowNegativeValue() { 401 | /* Given */ 402 | let flags = RowerDataCharacteristicFlags.create(resistanceLevelPresent: true) 403 | let data = CharacteristicData.create(flags: flags, values: 0b11111111, 0b11111111) 404 | 405 | /* When */ 406 | let result = RowerDataCharacteristic.decode(data: data) 407 | 408 | /* Then */ 409 | XCTAssertEqual(result.resistanceLevel, -1) 410 | } 411 | 412 | func test_resistanceLevel_present_resultsIn_sint16Value_forHighNegativeValue() { 413 | /* Given */ 414 | let flags = RowerDataCharacteristicFlags.create(resistanceLevelPresent: true) 415 | let data = CharacteristicData.create(flags: flags, values: 0b11111111, 0b11111110) 416 | 417 | /* When */ 418 | let result = RowerDataCharacteristic.decode(data: data) 419 | 420 | /* Then */ 421 | XCTAssertEqual(result.resistanceLevel, -257) 422 | } 423 | 424 | // MARK: Total Energy 425 | 426 | func test_totalEnergy_notPresent_resultsIn_nilValue() { 427 | /* Given */ 428 | let flags = RowerDataCharacteristicFlags.create(expendedEnergyPresent: false) 429 | let data = CharacteristicData.create(flags: flags) 430 | 431 | /* When */ 432 | let result = RowerDataCharacteristic.decode(data: data) 433 | 434 | /* Then */ 435 | XCTAssertNil(result.totalEnergyKiloCalories) 436 | } 437 | 438 | func test_totalEnergy_present_resultsIn_uint16Value_forLowValue() { 439 | /* Given */ 440 | let flags = RowerDataCharacteristicFlags.create(expendedEnergyPresent: true) 441 | let data = CharacteristicData.create( 442 | flags: flags, 443 | values: 1, // Total energy value 1 444 | 0, 445 | 0, // Energy per hour value 446 | 0, 447 | 0, // Energy per minute value 448 | 0 449 | ) 450 | 451 | /* When */ 452 | let result = RowerDataCharacteristic.decode(data: data) 453 | 454 | /* Then */ 455 | XCTAssertEqual(result.totalEnergyKiloCalories, 1) 456 | } 457 | 458 | func test_totalEnergy_present_resultsIn_uint16Value_forHighValue() { 459 | /* Given */ 460 | let flags = RowerDataCharacteristicFlags.create(expendedEnergyPresent: true) 461 | let data = CharacteristicData.create( 462 | flags: flags, 463 | values: 1, // Total energy value 1 + 512 464 | 2, 465 | 0, // Energy per hour value 466 | 0, 467 | 0, // Energy per minute value 468 | 0 469 | ) 470 | 471 | /* When */ 472 | let result = RowerDataCharacteristic.decode(data: data) 473 | 474 | /* Then */ 475 | XCTAssertEqual(result.totalEnergyKiloCalories, 513) 476 | } 477 | 478 | // See section 4.8.1.11 of the FTMS Bluetooth Service specification. 479 | func test_totalEnergy_presentButNotSupported_resultsIn_nilValue() { 480 | // If this field has to be present (i.e., if the Expended Energy Present bit of the Flags field is set to 1) 481 | // but the Server does not support the calculation of the Total Energy, the Server shall use the special 482 | // value 0xFFFF (i.e., decimal value of 65535 in UINT16 format), which means ‘Data Not Available’. 483 | 484 | /* Given */ 485 | let flags = RowerDataCharacteristicFlags.create(expendedEnergyPresent: true) 486 | let data = CharacteristicData.create( 487 | flags: flags, 488 | values: 0xff, // Total energy value 65535 489 | 0xff, 490 | 0, // Energy per hour value 491 | 0, 492 | 0, // Energy per minute value 493 | 0 494 | ) 495 | 496 | /* When */ 497 | let result = RowerDataCharacteristic.decode(data: data) 498 | 499 | /* Then */ 500 | XCTAssertNil(result.totalEnergyKiloCalories) 501 | } 502 | 503 | // MARK: Energy Per Hour 504 | 505 | func test_energyPerHour_notPresent_resultsIn_nilValue() { 506 | /* Given */ 507 | let flags = RowerDataCharacteristicFlags.create(expendedEnergyPresent: false) 508 | let data = CharacteristicData.create(flags: flags) 509 | 510 | /* When */ 511 | let result = RowerDataCharacteristic.decode(data: data) 512 | 513 | /* Then */ 514 | XCTAssertNil(result.energyPerHourKiloCalories) 515 | } 516 | 517 | func test_energyPerHour_present_resultsIn_uint16Value_forLowValue() { 518 | /* Given */ 519 | let flags = RowerDataCharacteristicFlags.create(expendedEnergyPresent: true) 520 | let data = CharacteristicData.create( 521 | flags: flags, 522 | values: 0, // Total energy value 523 | 0, 524 | 1, // Energy per hour value 1 525 | 0, 526 | 0, // Energy per minute value 527 | 0 528 | ) 529 | 530 | /* When */ 531 | let result = RowerDataCharacteristic.decode(data: data) 532 | 533 | /* Then */ 534 | XCTAssertEqual(result.energyPerHourKiloCalories, 1) 535 | } 536 | 537 | func test_energyPerHour_present_resultsIn_uint16Value_forHighValue() { 538 | /* Given */ 539 | let flags = RowerDataCharacteristicFlags.create(expendedEnergyPresent: true) 540 | let data = CharacteristicData.create( 541 | flags: flags, 542 | values: 0, // Total energy value 543 | 0, 544 | 1, // Energy per hour value 1 + 512 545 | 2, 546 | 0, // Energy per minute value 547 | 0 548 | ) 549 | 550 | /* When */ 551 | let result = RowerDataCharacteristic.decode(data: data) 552 | 553 | /* Then */ 554 | XCTAssertEqual(result.energyPerHourKiloCalories, 513) 555 | } 556 | 557 | // See section 4.8.1.12 of the FTMS Bluetooth Service specification. 558 | func test_energyPerHour_presentButNotSupported_resultsIn_nilValue() { 559 | // If this field has to be present (i.e., if the Expended Energy Present bit of the Flags field is set to 1) 560 | // but the Server does not support the calculation of the Energy per Hour, the Server shall use the special 561 | // value 0xFFFF (i.e., decimal value of 65535 in UINT16 format), which means ‘Data Not Available’. 562 | 563 | /* Given */ 564 | let flags = RowerDataCharacteristicFlags.create(expendedEnergyPresent: true) 565 | let data = CharacteristicData.create( 566 | flags: flags, 567 | values: 0, // Total energy value 568 | 0, 569 | 0xFF, // Energy per hour value 65535 570 | 0xFF, 571 | 0, // Energy per minute value 572 | 0 573 | ) 574 | 575 | /* When */ 576 | let result = RowerDataCharacteristic.decode(data: data) 577 | 578 | /* Then */ 579 | XCTAssertNil(result.energyPerHourKiloCalories) 580 | } 581 | 582 | // MARK: Energy Per Minute 583 | 584 | func test_energyPerMinute_notPresent_resultsIn_nilValue() { 585 | /* Given */ 586 | let flags = RowerDataCharacteristicFlags.create(expendedEnergyPresent: false) 587 | let data = CharacteristicData.create(flags: flags) 588 | 589 | /* When */ 590 | let result = RowerDataCharacteristic.decode(data: data) 591 | 592 | /* Then */ 593 | XCTAssertNil(result.energyPerMinuteKiloCalories) 594 | } 595 | 596 | func test_energyPerMinute_present_resultsIn_uint8Value() { 597 | /* Given */ 598 | let flags = RowerDataCharacteristicFlags.create(expendedEnergyPresent: true) 599 | let data = CharacteristicData.create( 600 | flags: flags, 601 | values: 0, // Total energy value 602 | 0, 603 | 0, // Energy per hour value 604 | 0, 605 | 1 // Energy per minute value 1 606 | ) 607 | 608 | /* When */ 609 | let result = RowerDataCharacteristic.decode(data: data) 610 | 611 | /* Then */ 612 | XCTAssertEqual(result.energyPerMinuteKiloCalories, 1) 613 | } 614 | 615 | // See section 4.8.1.13 of the FTMS Bluetooth Service specification. 616 | func test_energyPerMinute_presentButNotSupported_resultsIn_nilValue() { 617 | // If this field has to be present (i.e., if the Expended Energy Present bit of the Flags field is set to 1) 618 | // but the Server does not support the calculation of the Energy per Minute, the Server shall use the special 619 | // value 0xFF (i.e., decimal value of 255 in UINT16 format), which means ‘Data Not Available’. 620 | 621 | /* Given */ 622 | let flags = RowerDataCharacteristicFlags.create(expendedEnergyPresent: true) 623 | let data = CharacteristicData.create( 624 | flags: flags, 625 | values: 0, // Total energy value 626 | 0, 627 | 0, // Energy per hour value 628 | 0, 629 | 0xFF // Energy per minute value 255 630 | ) 631 | 632 | /* When */ 633 | let result = RowerDataCharacteristic.decode(data: data) 634 | 635 | /* Then */ 636 | XCTAssertNil(result.energyPerMinuteKiloCalories) 637 | } 638 | 639 | // MARK: Heart Rate 640 | 641 | func test_heartRate_notPresent_resultsIn_nilValue() { 642 | /* Given */ 643 | let flags = RowerDataCharacteristicFlags.create(heartRatePresent: false) 644 | let data = CharacteristicData.create(flags: flags) 645 | 646 | /* When */ 647 | let result = RowerDataCharacteristic.decode(data: data) 648 | 649 | /* Then */ 650 | XCTAssertNil(result.heartRate) 651 | } 652 | 653 | func test_heartRate_present_resultsIn_uint8Value() { 654 | /* Given */ 655 | let flags = RowerDataCharacteristicFlags.create(heartRatePresent: true) 656 | let data = CharacteristicData.create( 657 | flags: flags, 658 | values: 170 // Heart rate of 170 659 | ) 660 | 661 | /* When */ 662 | let result = RowerDataCharacteristic.decode(data: data) 663 | 664 | /* Then */ 665 | XCTAssertEqual(result.heartRate, 170) 666 | } 667 | 668 | // MARK: Metabolic Equivalent 669 | 670 | func test_metabolicEquivalent_notPresent_resultsIn_nilValue() { 671 | /* Given */ 672 | let flags = RowerDataCharacteristicFlags.create(metabolicEquivalentPresent: false) 673 | let data = CharacteristicData.create(flags: flags) 674 | 675 | /* When */ 676 | let result = RowerDataCharacteristic.decode(data: data) 677 | 678 | /* Then */ 679 | XCTAssertNil(result.metabolicEquivalent) 680 | } 681 | 682 | func test_metabolicEquivalent_present_resultsIn_uint8Value() { 683 | /* Given */ 684 | let flags = RowerDataCharacteristicFlags.create(metabolicEquivalentPresent: true) 685 | let data = CharacteristicData.create( 686 | flags: flags, 687 | values: 123 // Metabolic Equivalent of 12.3 688 | ) 689 | 690 | /* When */ 691 | let result = RowerDataCharacteristic.decode(data: data) 692 | 693 | /* Then */ 694 | XCTAssertEqual(result.metabolicEquivalent, 12.3) 695 | } 696 | 697 | // MARK: Elapsed Time 698 | 699 | func test_elapsedTime_notPresent_resultsIn_nilValue() { 700 | /* Given */ 701 | let flags = RowerDataCharacteristicFlags.create(elapsedTimePresent: false) 702 | let data = CharacteristicData.create(flags: flags) 703 | 704 | /* When */ 705 | let result = RowerDataCharacteristic.decode(data: data) 706 | 707 | /* Then */ 708 | XCTAssertNil(result.elapsedTimeSeconds) 709 | } 710 | 711 | func test_elapsedTime_present_resultsIn_uint16Value_forLowValue() { 712 | /* Given */ 713 | let flags = RowerDataCharacteristicFlags.create(elapsedTimePresent: true) 714 | let data = CharacteristicData.create( 715 | flags: flags, 716 | values: 3, 717 | 0 718 | ) 719 | 720 | /* When */ 721 | let result = RowerDataCharacteristic.decode(data: data) 722 | 723 | /* Then */ 724 | XCTAssertEqual(result.elapsedTimeSeconds, 3) 725 | } 726 | 727 | func test_elapsedTime_present_resultsIn_uint16Value_forHighValue() { 728 | /* Given */ 729 | let flags = RowerDataCharacteristicFlags.create(elapsedTimePresent: true) 730 | let data = CharacteristicData.create( 731 | flags: flags, 732 | values: 1, // 1 + 512 733 | 2 734 | ) 735 | 736 | /* When */ 737 | let result = RowerDataCharacteristic.decode(data: data) 738 | 739 | /* Then */ 740 | XCTAssertEqual(result.elapsedTimeSeconds, 513) 741 | } 742 | 743 | // MARK: Remaining Time 744 | 745 | func test_remainingTime_notPresent_resultsIn_nilValue() { 746 | /* Given */ 747 | let flags = RowerDataCharacteristicFlags.create(remainingTimePresent: false) 748 | let data = CharacteristicData.create(flags: flags) 749 | 750 | /* When */ 751 | let result = RowerDataCharacteristic.decode(data: data) 752 | 753 | /* Then */ 754 | XCTAssertNil(result.remainingTimeSeconds) 755 | } 756 | 757 | func test_remainingTime_present_resultsIn_uint16Value_forLowValue() { 758 | /* Given */ 759 | let flags = RowerDataCharacteristicFlags.create(remainingTimePresent: true) 760 | let data = CharacteristicData.create( 761 | flags: flags, 762 | values: 3, 763 | 0 764 | ) 765 | 766 | /* When */ 767 | let result = RowerDataCharacteristic.decode(data: data) 768 | 769 | /* Then */ 770 | XCTAssertEqual(result.remainingTimeSeconds, 3) 771 | } 772 | 773 | func test_remainingTime_present_resultsIn_uint16Value_forHighValue() { 774 | /* Given */ 775 | let flags = RowerDataCharacteristicFlags.create(remainingTimePresent: true) 776 | let data = CharacteristicData.create( 777 | flags: flags, 778 | values: 1, // 1 + 512 779 | 2 780 | ) 781 | 782 | /* When */ 783 | let result = RowerDataCharacteristic.decode(data: data) 784 | 785 | /* Then */ 786 | XCTAssertEqual(result.remainingTimeSeconds, 513) 787 | } 788 | 789 | // MARK: Multiple properties present 790 | 791 | func test_multiplePropertiesPresent_properlyOffsetsValues_forAverageStrokeRateAndTotalDistance() { 792 | /* Given */ 793 | let flags = RowerDataCharacteristicFlags.create( 794 | averageStrokeRatePresent: true, 795 | totalDistancePresent: true 796 | ) 797 | let data = CharacteristicData.create( 798 | flags: flags, 799 | values: 7, // Average stroke rate of 3.5 800 | 16, // Total distance of 16 801 | 0, 802 | 0 803 | ) 804 | 805 | /* When */ 806 | let result = RowerDataCharacteristic.decode(data: data) 807 | 808 | /* Then */ 809 | XCTAssertEqual(result.averageStrokeRate, 3.5) 810 | XCTAssertEqual(result.totalDistanceMeters, 16) 811 | } 812 | 813 | func test_multiplePropertiesPresent_properlyOffsetsValues_forAverageStrokeRateAndInstantaneousPace() { 814 | /* Given */ 815 | let flags = RowerDataCharacteristicFlags.create( 816 | averageStrokeRatePresent: true, 817 | instantaneousPacePresent: true 818 | ) 819 | let data = CharacteristicData.create( 820 | flags: flags, 821 | values: 7, // Average stroke rate of 3.5 822 | 16, // Instantaneous pace of 16 823 | 0 824 | ) 825 | 826 | /* When */ 827 | let result = RowerDataCharacteristic.decode(data: data) 828 | 829 | /* Then */ 830 | XCTAssertEqual(result.averageStrokeRate, 3.5) 831 | XCTAssertEqual(result.instantaneousPaceSeconds, 16) 832 | } 833 | 834 | func test_multiplePropertiesPresent_properlyOffsetsValues_forTotalDistanceAndInstantaneousPace() { 835 | /* Given */ 836 | let flags = RowerDataCharacteristicFlags.create( 837 | totalDistancePresent: true, 838 | instantaneousPacePresent: true 839 | ) 840 | let data = CharacteristicData.create( 841 | flags: flags, 842 | values: 32, // Total distance of 32 843 | 0, 844 | 0, 845 | 16, // Instantaneous pace of 16 846 | 0 847 | ) 848 | 849 | /* When */ 850 | let result = RowerDataCharacteristic.decode(data: data) 851 | 852 | /* Then */ 853 | XCTAssertEqual(result.totalDistanceMeters, 32) 854 | XCTAssertEqual(result.instantaneousPaceSeconds, 16) 855 | } 856 | 857 | // This is not really representative (not enough available bytes in a real world scenario), 858 | // but a good test to execute anyway to test dependencies. 859 | func test_allPropertiesPresent() { 860 | /* Given */ 861 | let flags = RowerDataCharacteristicFlags.create( 862 | moreDataPresent: true, 863 | averageStrokeRatePresent: true, 864 | totalDistancePresent: true, 865 | instantaneousPacePresent: true, 866 | averagePacePresent: true, 867 | instantaneousPowerPresent: true, 868 | averagePowerPresent: true, 869 | resistanceLevelPresent: true, 870 | expendedEnergyPresent: true, 871 | heartRatePresent: true, 872 | metabolicEquivalentPresent: true, 873 | elapsedTimePresent: true, 874 | remainingTimePresent: true 875 | ) 876 | 877 | let data = CharacteristicData.create( 878 | flags: flags, 879 | values: 1, // Stroke rate of 0.5 880 | 2, // Stroke count of 2 881 | 0, 882 | 3, // Average stroke rate of 1.5 883 | 4, // Total distance of 4 884 | 0, 885 | 0, 886 | 5, // Instantaneous pace of 5 887 | 0, 888 | 6, // Average pace of 6 889 | 0, 890 | 7, // Instantaneous power of 7, 891 | 0, 892 | 8, // Average power of 8 893 | 0, 894 | 9, // Resistance level of 9 895 | 0, 896 | 10, // Total energy of 10 897 | 0, 898 | 11, // Energy per hour of 11 899 | 0, 900 | 12, // Energy per minute of 12 901 | 13, // Heart rate of 13 902 | 14, // Metabolic equivalent of 1.4 903 | 15, // Elapsed time of 15 904 | 0, 905 | 16, // Time remaining of 16 906 | 0 907 | ) 908 | 909 | /* When */ 910 | let result = RowerDataCharacteristic.decode(data: data) 911 | 912 | /* Then */ 913 | XCTAssertEqual(result.strokeRate, 0.5) 914 | XCTAssertEqual(result.strokeCount, 2) 915 | XCTAssertEqual(result.averageStrokeRate, 1.5) 916 | XCTAssertEqual(result.totalDistanceMeters, 4) 917 | XCTAssertEqual(result.instantaneousPaceSeconds, 5) 918 | XCTAssertEqual(result.averagePaceSeconds, 6) 919 | XCTAssertEqual(result.instantaneousPowerWatts, 7) 920 | XCTAssertEqual(result.averagePowerWatts, 8) 921 | XCTAssertEqual(result.resistanceLevel, 9) 922 | XCTAssertEqual(result.totalEnergyKiloCalories, 10) 923 | XCTAssertEqual(result.energyPerHourKiloCalories, 11) 924 | XCTAssertEqual(result.energyPerMinuteKiloCalories, 12) 925 | XCTAssertEqual(result.heartRate, 13) 926 | XCTAssertEqual(result.metabolicEquivalent, 1.4) 927 | XCTAssertEqual(result.elapsedTimeSeconds, 15) 928 | XCTAssertEqual(result.remainingTimeSeconds, 16) 929 | } 930 | } 931 | --------------------------------------------------------------------------------