├── .gitignore ├── .idea ├── .name ├── codeStyles │ └── Project.xml ├── compiler.xml ├── jarRepositories.xml └── misc.xml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── libs │ └── antpluginlib_3-8-0.aar ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── idv │ │ └── markkuo │ │ └── cscblebridge │ │ ├── LaunchActivity.kt │ │ ├── MainFragment.kt │ │ ├── antrecyclerview │ │ ├── AntDeviceRecyclerViewAdapter.kt │ │ ├── AntDeviceView.kt │ │ ├── AntDeviceViewHolder.kt │ │ └── BroadcastButtonView.kt │ │ └── service │ │ ├── AntToBleBridge.kt │ │ ├── MainService.kt │ │ ├── ant │ │ ├── AntDevice.kt │ │ ├── AntDeviceConnector.kt │ │ ├── BcConnector.kt │ │ ├── BsdConnector.kt │ │ ├── HRConnector.kt │ │ └── SSConnector.kt │ │ └── ble │ │ ├── BleServer.kt │ │ └── BleServiceType.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ ├── ic_baseline_bluetooth_24.xml │ └── ic_baseline_stop_24.xml │ ├── layout │ ├── activity_launch.xml │ ├── ant_list_item.xml │ ├── broadcast_view.xml │ └── fragment_main.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ └── values │ ├── colors.xml │ ├── dimens.xml │ ├── strings.xml │ └── styles.xml ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── screenshots ├── screenshot_1.jpg ├── screenshot_2.jpg ├── screenshot_3.jpg └── screenshot_v1.3.jpg └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | # from https://raw.githubusercontent.com/github/gitignore/master/Android.gitignore 2 | 3 | .DS_Store 4 | 5 | # Built application files 6 | *.apk 7 | *.aar 8 | *.ap_ 9 | *.aab 10 | 11 | # Files for the ART/Dalvik VM 12 | *.dex 13 | 14 | # Java class files 15 | *.class 16 | 17 | # Generated files 18 | bin/ 19 | gen/ 20 | out/ 21 | # Uncomment the following line in case you need and you don't have the release build type files in your app 22 | release/ 23 | 24 | # Gradle files 25 | .gradle/ 26 | build/ 27 | 28 | # Local configuration file (sdk path, etc) 29 | local.properties 30 | 31 | # Proguard folder generated by Eclipse 32 | proguard/ 33 | 34 | # Log Files 35 | *.log 36 | 37 | # Android Studio Navigation editor temp files 38 | .navigation/ 39 | 40 | # Android Studio captures folder 41 | captures/ 42 | 43 | # IntelliJ 44 | *.iml 45 | .idea/workspace.xml 46 | .idea/tasks.xml 47 | .idea/gradle.xml 48 | .idea/assetWizardSettings.xml 49 | .idea/dictionaries 50 | .idea/libraries 51 | # Android Studio 3 in .gitignore file. 52 | .idea/caches 53 | .idea/modules.xml 54 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you 55 | .idea/navEditor.xml 56 | 57 | # Keystore files 58 | # Uncomment the following lines if you do not want to check your keystore files in. 59 | #*.jks 60 | #*.keystore 61 | 62 | # External native build folder generated in Android Studio 2.2 and later 63 | .externalNativeBuild 64 | .cxx/ 65 | 66 | # Google Services (e.g. APIs or Firebase) 67 | # google-services.json 68 | 69 | # Freeline 70 | freeline.py 71 | freeline/ 72 | freeline_project_description.json 73 | 74 | # fastlane 75 | fastlane/report.xml 76 | fastlane/Preview.html 77 | fastlane/screenshots 78 | fastlane/test_output 79 | fastlane/readme.md 80 | 81 | # Version control 82 | vcs.xml 83 | 84 | # lint 85 | lint/intermediates/ 86 | lint/generated/ 87 | lint/outputs/ 88 | lint/tmp/ 89 | # lint/reports/ 90 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | CSC BLE Bridge -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 20 | 21 | 22 | 23 | 25 | 26 | 27 |
28 | 29 | 30 | 31 | xmlns:android 32 | 33 | ^$ 34 | 35 | 36 | 37 |
38 |
39 | 40 | 41 | 42 | xmlns:.* 43 | 44 | ^$ 45 | 46 | 47 | BY_NAME 48 | 49 |
50 |
51 | 52 | 53 | 54 | .*:id 55 | 56 | http://schemas.android.com/apk/res/android 57 | 58 | 59 | 60 |
61 |
62 | 63 | 64 | 65 | .*:name 66 | 67 | http://schemas.android.com/apk/res/android 68 | 69 | 70 | 71 |
72 |
73 | 74 | 75 | 76 | name 77 | 78 | ^$ 79 | 80 | 81 | 82 |
83 |
84 | 85 | 86 | 87 | style 88 | 89 | ^$ 90 | 91 | 92 | 93 |
94 |
95 | 96 | 97 | 98 | .* 99 | 100 | ^$ 101 | 102 | 103 | BY_NAME 104 | 105 |
106 |
107 | 108 | 109 | 110 | .* 111 | 112 | http://schemas.android.com/apk/res/android 113 | 114 | 115 | ANDROID_ATTRIBUTE_ORDER 116 | 117 |
118 |
119 | 120 | 121 | 122 | .* 123 | 124 | .* 125 | 126 | 127 | BY_NAME 128 | 129 |
130 |
131 |
132 |
133 |
134 |
-------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | 29 | 30 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mark Kuo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # What 3 | 4 | This is an Ant+ to BLE (Bluetooth Low Energy) Bridging app for ANT+ Cycling Speed and Cadence (CSC) sensors, ANT+ heart rate sensors, and ANT+ Stride based speed and distance sensors. 5 | The app will act as a Cycling speed/cadence and/or HR BLE sensor and/or BLE Running speed/cadence so other BLE device (phone, PC, Mac, tablet) can reads data from ANT+ sensors. 6 | 7 | 8 | 9 | V1.3 screenshot | pre V1.3 screenshot (old original version) 10 | :-------------------------:|:-------------------------: 11 | ![Screenshot v1.3](screenshots/screenshot_v1.3.jpg) | ![Screenshot](screenshots/screenshot_3.jpg) 12 | 13 | # Install 14 | 15 | [](https://apt.izzysoft.de/fdroid/index/apk/idv.markkuo.cscblebridge) 16 | 17 | Or you can download the apk in [release](https://github.com/starryalley/CSC_BLE_Bridge/releases) and use `adb` to install: 18 | 19 | ```adb install -r /path/to/app-release.apk``` 20 | 21 | # Details 22 | 23 | This is an Android app which reads ANT+ cycling speed/cadence (CSC), heart rate sensors, and/or stride based speed and distance (SDM) sensors in the background (actually in an Android foreground service), and advertises itself as a `Bluetooth Low Energy (BLE)` device which implements `Cycling Speed and Cadence Profile (CSCP)`, `Heart Rate Profile (HRP)`, and/or `Running Speed and Cadence (rsc)` so that other devices can see this Android device as a Cycling Speed & Cadence Bluetooth, Heart Rate BLE sensor and/or Running foot pod sensor. 24 | 25 | This is useful when you only have ANT+ CSC/HR sensors but you want to connect to them as BLE sensors (provided that you don't have an USB ANT+ stick around but happen to have an ANT+ enabled Android device like a Samsung phone). 26 | 27 | Long hold on the sensors name on top of the screen to rescan for Ant+ devices after the "Start BLE bridging" button has been pressed. All sensors will automatically scan for Ant+ sources when the "Start BLE bridging" button is pressed. 28 | 29 | # Why 30 | 31 | I want to try out Zwift desktop version but I can't get a USB ANT+ Stick anywhere now in my country (due to the C-19 pandemic). So I decided to write one. 32 | 33 | I can now sees my speed and cadence coming from ANT+ sensors on Zwift Mac without the need of extra hardware. 34 | 35 | Special thanks to [pinkemma](https://github.com/pinkemma) who implements Heart Rate sensor profile so that this app also reads heart rate from ANT+ sensor and bridges it to bluetooth. (see [PR #5](https://github.com/starryalley/CSC_BLE_Bridge/pull/5) since 10/2020) 36 | 37 | # Tested devices 38 | 39 | ## ANT+ and BLE enabled Android devices 40 | - Samsung Galaxy S8 (Android 9) 41 | - Samsung Galaxy J5 2016 - Thanks [louisJ20](https://github.com/louisJ20) 42 | - Samsung Galaxy S9 (Android 10) - Thanks [pinkemma](https://github.com/pinkemma) 43 | - One Plus 7 Pro (Android 10) - Thanks [michaelrhughes](https://github.com/michaelrhughes) 44 | - Nokia 7 Plus (Android 10) - Thanks [leaskovski](https://github.com/leaskovski) 45 | - Nexus 5 (CyanogenMod 13 and Ant+ Enabler) - Thanks [leaskovski](https://github.com/leaskovski) 46 | - OnePlus 5T (Android 10) - Thanks [philharle](https://github.com/philharle) 47 | - Galaxy Note 9 - Thanks [larryb84](https://github.com/larryb84) 48 | - Samsung Galaxy S10 (Android 11) - Thanks [Chris](https://github.com/CS-Biker) 49 | 50 | ## ANT+ Speed/Cadence sensors 51 | - [Garmin gen 1 ANT+ only speed and cadence sensor](https://buy.garmin.com/en-MW/ssa/p/146897) 52 | - [Garmin speed cadence combined sensor](https://www.thisisant.com/directory/gsc-10-speed-cadence-bike-sensor) - Thanks [louisJ20](https://github.com/louisJ20) 53 | 54 | ## ANT+ Heart Rate sensors 55 | - [Garmin HRM-Run](https://buy.garmin.com/en-AU/AU/p/530376) 56 | - Garmin HRM3-SS - Thanks [philharle](https://github.com/philharle) 57 | - Garmin Fenix 5s with broadcasting mode on (ANT+ signal) - Thanks [pinkemma](https://github.com/pinkemma) 58 | - Garmin 935 with broadcasting mode on (ANT+ signal) 59 | - Garmin Fenix 6 with broadcasting mode on (ANT+ signal) 60 | - Garmin HRM2 - Thanks [larryb84](https://github.com/larryb84) 61 | - Garmin Fenix 5 with broadcasting mode on (ANT+ signal) - Thanks [Chris](https://github.com/CS-Biker) 62 | - Garmin HRM Tri - Thanks [Chris](https://github.com/CS-Biker) 63 | 64 | ## ANT+ Stride-based Speed and Distance Monitor (SDM) sensors 65 | - Garmin Foot Pod 66 | 67 | ## Apps that use BLE sensors 68 | - Zwift (Mac/iPad/Windows version) 69 | - The Sufferfest (Mac version) 70 | - Zwift (iPad Pro) 71 | - theoretically all other apps that use Bike Speed/Cadence or Heart Rate or Run Speed/Cadence BLE sensors 72 | 73 | 74 | # Known issues 75 | 76 | - On my Samsung S8 sometimes the bluetooth PHY is messed up (for unknown reason). I have to reboot the phone to get it working. (When it doesn't work, the advertising reports success but actually you can't see any). 77 | 78 | # Reference 79 | 80 | Code samples: 81 | - [Bluetooth GATT Server Sample](https://github.com/androidthings/sample-bluetooth-le-gattserver) 82 | - [Android ANT+ SDK sample](https://www.thisisant.com/resources/android-ant-sdk/) 83 | 84 | Spec and Document 85 | - [ANT+ Basic](https://www.thisisant.com/developer/ant/ant-basics) 86 | - [Introduction to Bluetooth low energy](https://learn.adafruit.com/introduction-to-bluetooth-low-energy/gatt) 87 | - [CH4 of Getting Started with Bluetooth Low Energy](https://www.oreilly.com/library/view/getting-started-with/9781491900550/ch04.html) 88 | - [Bluetooth GATT Specifications](https://www.bluetooth.com/specifications/gatt) 89 | - Cycling Speed and Cadence Profile/Service 90 | - Heart Rate Profile/Service 91 | - [Bluetooth Assigned numbers](https://www.bluetooth.com/specifications/assigned-numbers/service-discovery/) 92 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | 4 | repositories { 5 | mavenCentral() 6 | } 7 | 8 | android { 9 | compileSdkVersion 33 10 | 11 | compileOptions { 12 | sourceCompatibility JavaVersion.VERSION_1_8 13 | targetCompatibility JavaVersion.VERSION_1_8 14 | } 15 | 16 | defaultConfig { 17 | applicationId "idv.markkuo.cscblebridge" 18 | minSdkVersion 21 19 | targetSdkVersion 33 20 | versionCode 6 21 | versionName "1.3.2" 22 | 23 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 24 | 25 | vectorDrawables.useSupportLibrary = true 26 | } 27 | 28 | buildTypes { 29 | release { 30 | minifyEnabled false 31 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 32 | } 33 | } 34 | namespace 'idv.markkuo.cscblebridge' 35 | 36 | } 37 | 38 | dependencies { 39 | implementation fileTree(dir: 'libs', include: ['*.jar']) 40 | 41 | implementation 'androidx.appcompat:appcompat:1.5.1' 42 | implementation 'androidx.constraintlayout:constraintlayout:2.1.4' 43 | implementation "androidx.recyclerview:recyclerview:1.2.1" 44 | implementation "androidx.core:core-ktx:1.9.0" 45 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 46 | implementation "androidx.cardview:cardview:1.0.0" 47 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2" 48 | 49 | testImplementation 'junit:junit:4.12' 50 | androidTestImplementation 'androidx.test.ext:junit:1.1.3' 51 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 52 | implementation fileTree(dir: 'libs', include: ['*.aar', '*.jar'], exclude: []) 53 | implementation files('libs/antpluginlib_3-8-0.aar') 54 | implementation "org.jetbrains.kotlin:kotlin-reflect:1.5.31" 55 | } -------------------------------------------------------------------------------- /app/libs/antpluginlib_3-8-0.aar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/starryalley/CSC_BLE_Bridge/bc153f2e7dd269246707dfc554bdab7c1fca4cbb/app/libs/antpluginlib_3-8-0.aar -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 23 | 25 | 26 | 27 | 28 | 29 | 30 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/java/idv/markkuo/cscblebridge/LaunchActivity.kt: -------------------------------------------------------------------------------- 1 | package idv.markkuo.cscblebridge 2 | 3 | import android.content.ComponentName 4 | import android.content.Intent 5 | import android.content.ServiceConnection 6 | import android.os.Bundle 7 | import android.os.IBinder 8 | import androidx.appcompat.app.AppCompatActivity 9 | import idv.markkuo.cscblebridge.service.MainService 10 | import idv.markkuo.cscblebridge.service.ant.AntDevice 11 | import idv.markkuo.cscblebridge.service.ble.BleServiceType 12 | import kotlinx.coroutines.Dispatchers 13 | import kotlinx.coroutines.runBlocking 14 | import kotlinx.coroutines.withContext 15 | 16 | class LaunchActivity: AppCompatActivity(), MainFragment.ServiceStarter, MainService.MainServiceListener { 17 | 18 | private var mService: MainService? = null 19 | private var serviceIntent: Intent? = null 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | super.onCreate(savedInstanceState) 23 | setContentView(R.layout.activity_launch) 24 | } 25 | 26 | override fun onResume() { 27 | super.onResume() 28 | startService() 29 | } 30 | 31 | override fun onStop() { 32 | unbind() 33 | super.onStop() 34 | } 35 | 36 | private val connection: ServiceConnection = object : ServiceConnection { 37 | override fun onServiceConnected(className: ComponentName, service: IBinder) { 38 | val binder = service as MainService.LocalBinder 39 | mService = binder.service 40 | binder.service.addListener(this@LaunchActivity) 41 | val mainFragment = mainFragment() 42 | mainFragment?.searching(binder.service.isSearching) 43 | } 44 | 45 | override fun onServiceDisconnected(arg0: ComponentName) { 46 | mService?.removeListener(this@LaunchActivity) 47 | mService = null 48 | } 49 | } 50 | 51 | override fun startService() { 52 | serviceIntent = Intent(this, MainService::class.java) 53 | startService(serviceIntent) 54 | bindService(serviceIntent, connection, 0) 55 | } 56 | 57 | override fun stopService() { 58 | runBlocking { 59 | withContext(Dispatchers.IO) { 60 | mService?.stopSearching() 61 | unbind() 62 | stopService(serviceIntent) 63 | } 64 | } 65 | } 66 | 67 | private fun unbind() { 68 | try { 69 | unbindService(connection) 70 | } catch (e: IllegalArgumentException) { 71 | // Expected if not bound 72 | } 73 | } 74 | 75 | override fun deviceSelected(antDevice: AntDevice) { 76 | mService?.deviceSelected(antDevice) 77 | } 78 | 79 | override fun isSearching(): Boolean = mService?.isSearching ?: false 80 | override fun searching(isSearching: Boolean) { 81 | mainFragment()?.searching(isSearching) 82 | } 83 | 84 | override fun onDevicesUpdated(devices: List, selectedDevices: Map>) { 85 | val mainFragment = mainFragment() 86 | mainFragment?.setDevices(devices, selectedDevices) 87 | } 88 | 89 | private fun mainFragment() = 90 | supportFragmentManager.findFragmentById(R.id.main_fragment) as MainFragment? 91 | } -------------------------------------------------------------------------------- /app/src/main/java/idv/markkuo/cscblebridge/MainFragment.kt: -------------------------------------------------------------------------------- 1 | package idv.markkuo.cscblebridge 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import android.widget.Button 8 | import androidx.fragment.app.Fragment 9 | import androidx.recyclerview.widget.LinearLayoutManager 10 | import androidx.recyclerview.widget.RecyclerView 11 | import idv.markkuo.cscblebridge.antrecyclerview.AntDeviceRecyclerViewAdapter 12 | import idv.markkuo.cscblebridge.service.ant.AntDevice 13 | import idv.markkuo.cscblebridge.service.ble.BleServiceType 14 | 15 | class MainFragment: Fragment() { 16 | 17 | interface ServiceStarter { 18 | fun startService() 19 | fun stopService() 20 | fun deviceSelected(antDevice: AntDevice) 21 | fun isSearching(): Boolean 22 | } 23 | 24 | private var antDeviceRecyclerViewAdapter: AntDeviceRecyclerViewAdapter? = null 25 | private lateinit var searchButton: Button 26 | 27 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 28 | super.onCreateView(inflater, container, savedInstanceState) 29 | val view = inflater.inflate(R.layout.fragment_main, container) 30 | 31 | searchButton = view.findViewById(R.id.searchButton) 32 | searchButton.setOnClickListener { 33 | val searching = (requireActivity() as ServiceStarter).isSearching() 34 | if (searching) { 35 | (activity as ServiceStarter).stopService() 36 | } else { 37 | (activity as ServiceStarter).startService() 38 | } 39 | updateSearchButtonText(!searching) 40 | } 41 | updateSearchButtonText((requireActivity() as ServiceStarter).isSearching()) 42 | 43 | val recyclerView = view.findViewById(R.id.main_recycler_view) 44 | recyclerView.layoutManager = LinearLayoutManager(view.context) 45 | antDeviceRecyclerViewAdapter = AntDeviceRecyclerViewAdapter { 46 | (activity as ServiceStarter).deviceSelected(it) 47 | } 48 | recyclerView.adapter = antDeviceRecyclerViewAdapter 49 | return view 50 | } 51 | 52 | private fun updateSearchButtonText(searching: Boolean) { 53 | searchButton.text = if (searching) getString(R.string.stop_service) else getString(R.string.start_service) 54 | } 55 | 56 | fun setDevices(devices: List, selectedDevices: Map>) { 57 | activity?.runOnUiThread { 58 | antDeviceRecyclerViewAdapter?.updateDevices(devices, selectedDevices) 59 | } 60 | } 61 | 62 | fun searching(isSearching: Boolean) { 63 | updateSearchButtonText(isSearching) 64 | } 65 | } -------------------------------------------------------------------------------- /app/src/main/java/idv/markkuo/cscblebridge/antrecyclerview/AntDeviceRecyclerViewAdapter.kt: -------------------------------------------------------------------------------- 1 | package idv.markkuo.cscblebridge.antrecyclerview 2 | 3 | import android.view.ViewGroup 4 | import androidx.recyclerview.widget.RecyclerView 5 | import idv.markkuo.cscblebridge.service.ant.AntDevice 6 | import idv.markkuo.cscblebridge.service.ble.BleServiceType 7 | 8 | class AntDeviceRecyclerViewAdapter(private val deviceSelected: (device: AntDevice) -> Unit): RecyclerView.Adapter() { 9 | private val deviceList = ArrayList() 10 | private var selectedDevices: Map>? = null 11 | 12 | fun updateDevices(devices: List, selectedDevices: Map>) { 13 | this.selectedDevices = selectedDevices 14 | deviceList.clear() 15 | deviceList.addAll(devices) 16 | // could do this better, but it seems to perform well enough with so few items 17 | notifyDataSetChanged() 18 | } 19 | 20 | fun addDevice(antDevice: AntDevice) { 21 | deviceList.add(antDevice) 22 | notifyItemInserted(deviceList.size - 1) 23 | } 24 | 25 | fun removeDevice(antDevice: AntDevice) { 26 | val index = deviceList.indexOf(antDevice) 27 | deviceList.remove(antDevice) 28 | notifyItemRemoved(index) 29 | } 30 | 31 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AntDeviceViewHolder { 32 | val view = AntDeviceView(parent.context) 33 | return AntDeviceViewHolder(view) 34 | } 35 | 36 | override fun getItemCount(): Int = deviceList.size 37 | 38 | override fun onBindViewHolder(holder: AntDeviceViewHolder, position: Int) { 39 | val antDevice = deviceList[position] 40 | holder.view.bind(antDevice, selectedDevices?.values?.flatten()?.contains(antDevice.deviceId) ?: false, deviceSelected) 41 | } 42 | } -------------------------------------------------------------------------------- /app/src/main/java/idv/markkuo/cscblebridge/antrecyclerview/AntDeviceView.kt: -------------------------------------------------------------------------------- 1 | package idv.markkuo.cscblebridge.antrecyclerview 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import android.view.ViewGroup 6 | import android.widget.FrameLayout 7 | import android.widget.LinearLayout 8 | import android.widget.TextView 9 | import androidx.recyclerview.widget.RecyclerView 10 | import idv.markkuo.cscblebridge.R 11 | import idv.markkuo.cscblebridge.service.ant.AntDevice 12 | 13 | class AntDeviceView @JvmOverloads constructor( 14 | context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 15 | ) : FrameLayout(context, attrs, defStyleAttr) { 16 | 17 | private val nameView: TextView 18 | private val typeView: TextView 19 | private val dataView: TextView 20 | private val background: LinearLayout 21 | private val broadcastButtonView: BroadcastButtonView 22 | 23 | init { 24 | inflate(context, R.layout.ant_list_item, this) 25 | layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) 26 | nameView = findViewById(R.id.ant_device_name) 27 | typeView = findViewById(R.id.ant_device_type) 28 | dataView = findViewById(R.id.ant_device_data) 29 | background = findViewById(R.id.ant_device_background) 30 | broadcastButtonView = findViewById(R.id.broadcast_button_view) 31 | } 32 | 33 | fun bind(antDevice: AntDevice, isSelected: Boolean, onClickListener: (antDevice: AntDevice) -> Unit) { 34 | val color = if (isSelected) { 35 | broadcastButtonView.setState(BroadcastButtonView.BroadcastButtonViewState.Broadcasting) 36 | context.resources.getColor(android.R.color.holo_blue_dark) 37 | } else { 38 | broadcastButtonView.setState(BroadcastButtonView.BroadcastButtonViewState.NotSelected) 39 | context.resources.getColor(android.R.color.black) 40 | } 41 | 42 | nameView.text = antDevice.deviceName 43 | nameView.setTextColor(color) 44 | typeView.text = antDevice.typeName 45 | dataView.text = antDevice.getDataString() 46 | background.setOnClickListener { onClickListener(antDevice) } 47 | broadcastButtonView.setClickListener { onClickListener(antDevice) } 48 | } 49 | } -------------------------------------------------------------------------------- /app/src/main/java/idv/markkuo/cscblebridge/antrecyclerview/AntDeviceViewHolder.kt: -------------------------------------------------------------------------------- 1 | package idv.markkuo.cscblebridge.antrecyclerview 2 | 3 | import androidx.recyclerview.widget.RecyclerView 4 | 5 | class AntDeviceViewHolder(val view: AntDeviceView): RecyclerView.ViewHolder(view) -------------------------------------------------------------------------------- /app/src/main/java/idv/markkuo/cscblebridge/antrecyclerview/BroadcastButtonView.kt: -------------------------------------------------------------------------------- 1 | package idv.markkuo.cscblebridge.antrecyclerview 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import android.view.View 6 | import android.widget.Button 7 | import android.widget.FrameLayout 8 | import android.widget.ImageView 9 | import idv.markkuo.cscblebridge.R 10 | 11 | class BroadcastButtonView @JvmOverloads constructor( 12 | context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 13 | ) : FrameLayout(context, attrs, defStyleAttr) { 14 | 15 | enum class BroadcastButtonViewState { 16 | NotSelected, 17 | Broadcasting 18 | } 19 | 20 | private val broadcastButtonBroadcast: Button 21 | private val bluetoothIcon: ImageView 22 | 23 | init { 24 | inflate(context, R.layout.broadcast_view, this) 25 | broadcastButtonBroadcast = findViewById(R.id.broadcast_button_broadcast) 26 | bluetoothIcon = findViewById(R.id.bluetooth) 27 | } 28 | 29 | fun setClickListener(clickListener: () -> Unit) { 30 | broadcastButtonBroadcast.setOnClickListener { 31 | clickListener() 32 | } 33 | } 34 | 35 | fun setState(state: BroadcastButtonViewState) { 36 | when (state) { 37 | BroadcastButtonViewState.NotSelected -> { 38 | broadcastButtonBroadcast.visibility = View.VISIBLE 39 | bluetoothIcon.visibility = View.GONE 40 | } 41 | BroadcastButtonViewState.Broadcasting -> { 42 | broadcastButtonBroadcast.visibility = View.GONE 43 | bluetoothIcon.visibility = View.VISIBLE 44 | } 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /app/src/main/java/idv/markkuo/cscblebridge/service/AntToBleBridge.kt: -------------------------------------------------------------------------------- 1 | package idv.markkuo.cscblebridge.service 2 | 3 | import android.content.Context 4 | import com.dsi.ant.plugins.antplus.pcc.defines.DeviceState 5 | import com.dsi.ant.plugins.antplus.pcc.defines.RequestAccessResult 6 | import idv.markkuo.cscblebridge.service.ant.* 7 | import idv.markkuo.cscblebridge.service.ble.BleServer 8 | import idv.markkuo.cscblebridge.service.ble.BleServiceType 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.runBlocking 11 | import kotlinx.coroutines.sync.Semaphore 12 | import kotlinx.coroutines.sync.withPermit 13 | import kotlinx.coroutines.withContext 14 | import java.util.ArrayList 15 | 16 | class AntToBleBridge { 17 | 18 | private val antConnectors = ArrayList>() 19 | private var bleServer: BleServer? = null 20 | 21 | val antDevices = hashMapOf() 22 | val selectedDevices = hashMapOf>() 23 | var serviceCallback: (() -> Unit)? = null 24 | var isSearching = false 25 | var lock = Semaphore(1) 26 | 27 | @Synchronized 28 | fun startup(service: Context, callback: () -> Unit) { 29 | serviceCallback = callback 30 | stop() 31 | isSearching = true 32 | antDevices.clear() 33 | bleServer = BleServer().apply { 34 | startServer(service) 35 | } 36 | 37 | runBlocking { 38 | lock.withPermit { 39 | antConnectors.add(createBsdConnector(service, callback)) 40 | 41 | antConnectors.add(createBcConnector(service, callback)) 42 | 43 | antConnectors.add(HRConnector(service, object: AntDeviceConnector.DeviceManagerListener { 44 | override fun onDeviceStateChanged(result: RequestAccessResult, deviceState: DeviceState) { 45 | } 46 | 47 | override fun onDataUpdated(data: AntDevice.HRDevice) { 48 | dataUpdated(data, BleServiceType.HrService, callback) { 49 | return@dataUpdated HRConnector(service, this) 50 | } 51 | } 52 | 53 | override fun onCombinedSensor(antDeviceConnector: AntDeviceConnector<*, *>, deviceId: Int) { 54 | // Not supported 55 | } 56 | })) 57 | 58 | antConnectors.add(SSConnector(service, object: AntDeviceConnector.DeviceManagerListener { 59 | override fun onDeviceStateChanged(result: RequestAccessResult, deviceState: DeviceState) { 60 | } 61 | 62 | override fun onDataUpdated(data: AntDevice.SSDevice) { 63 | dataUpdated(data, BleServiceType.RscService, callback) { 64 | return@dataUpdated SSConnector(service, this) 65 | } 66 | } 67 | 68 | override fun onCombinedSensor(antDeviceConnector: AntDeviceConnector<*, *>, deviceId: Int) { 69 | // Not supported 70 | } 71 | })) 72 | 73 | antConnectors.forEach { connector -> connector.startSearch() } 74 | } 75 | } 76 | 77 | } 78 | 79 | private fun createBsdConnector(service: Context, callback: () -> Unit, isCombinedSensor: Boolean = false): BsdConnector { 80 | return BsdConnector(service, object : AntDeviceConnector.DeviceManagerListener { 81 | override fun onDeviceStateChanged(result: RequestAccessResult, deviceState: DeviceState) { 82 | } 83 | 84 | override fun onDataUpdated(data: AntDevice.BsdDevice) { 85 | dataUpdated(data, BleServiceType.CscService, callback) { 86 | return@dataUpdated BsdConnector(service, this) 87 | } 88 | } 89 | 90 | override fun onCombinedSensor(antDeviceConnector: AntDeviceConnector<*, *>, deviceId: Int) { 91 | runBlocking { 92 | lock.withPermit { 93 | val bcConnector = antConnectors.firstOrNull { it is BcConnector } as BcConnector? 94 | if (bcConnector?.isCombinedSensor == false) { 95 | bcConnector.stopSearch() 96 | antConnectors.remove(bcConnector) 97 | antConnectors.add(createBcConnector(service, callback, true)) 98 | } 99 | } 100 | } 101 | } 102 | }, isCombinedSensor) 103 | } 104 | 105 | private fun createBcConnector(service: Context, callback: () -> Unit, isCombinedSensor: Boolean = false): BcConnector { 106 | return BcConnector(service, object : AntDeviceConnector.DeviceManagerListener { 107 | override fun onDeviceStateChanged(result: RequestAccessResult, deviceState: DeviceState) { 108 | } 109 | 110 | override fun onDataUpdated(data: AntDevice.BcDevice) { 111 | dataUpdated(data, BleServiceType.CscService, callback) { 112 | return@dataUpdated BcConnector(service, this) 113 | } 114 | } 115 | 116 | override fun onCombinedSensor(antDeviceConnector: AntDeviceConnector<*, *>, deviceId: Int) { 117 | runBlocking { 118 | lock.withPermit { 119 | val bsdConnector = antConnectors.firstOrNull { it is BsdConnector } as BsdConnector? 120 | if (bsdConnector?.isCombinedSensor == false) { 121 | bsdConnector.stopSearch() 122 | antConnectors.remove(bsdConnector) 123 | createBsdConnector(service, callback, true) 124 | } 125 | } 126 | } 127 | } 128 | }, isCombinedSensor) 129 | } 130 | 131 | @Synchronized 132 | private fun dataUpdated(data: AntDevice, type: BleServiceType, serviceCallback: () -> Unit, createService: () -> AntDeviceConnector<*, *>) { 133 | val isNew = !antDevices.containsKey(data.deviceId) 134 | antDevices[data.deviceId] = data 135 | bleServer?.updateData(type, data) 136 | if (isNew) { 137 | val connector = createService() 138 | runBlocking { 139 | lock.withPermit { 140 | antConnectors.add(connector) 141 | } 142 | } 143 | connector.startSearch() 144 | } 145 | 146 | // First selectedDevice selection 147 | if (!selectedDevices.containsKey(type)) { 148 | selectedDevices[type] = arrayListOf(data.deviceId) 149 | selectedDevicesUpdated() 150 | } else { 151 | if (type == BleServiceType.CscService) { 152 | // Bsc and Ble devices supported 153 | selectedDevices[type]?.let { devices -> 154 | val existingDevice = selectedDevices[type]?.firstOrNull { antDevices[it]?.typeName == data.typeName } 155 | if (existingDevice == null) { 156 | devices.add(data.deviceId) 157 | selectedDevicesUpdated() 158 | } 159 | } 160 | } 161 | } 162 | serviceCallback() 163 | } 164 | 165 | @Synchronized 166 | fun deviceSelected(data: AntDevice) { 167 | val arrayList = selectedDevices[data.bleType] ?: arrayListOf() 168 | val existingDevice = arrayList.firstOrNull { antDevices[it]?.typeName == data.typeName } 169 | if (existingDevice != null) { 170 | arrayList.remove(existingDevice) 171 | } 172 | arrayList.add(data.deviceId) 173 | selectedDevicesUpdated() 174 | serviceCallback?.invoke() 175 | } 176 | 177 | private fun selectedDevicesUpdated() { 178 | bleServer?.selectedDevices = selectedDevices 179 | } 180 | 181 | fun stop() { 182 | isSearching = false 183 | 184 | runBlocking { 185 | withContext(Dispatchers.IO) { 186 | lock.withPermit { 187 | antConnectors.forEach { connector -> connector.stopSearch() } 188 | antConnectors.clear() 189 | bleServer?.stopServer() 190 | 191 | serviceCallback = null 192 | } 193 | } 194 | } 195 | } 196 | } -------------------------------------------------------------------------------- /app/src/main/java/idv/markkuo/cscblebridge/service/MainService.kt: -------------------------------------------------------------------------------- 1 | package idv.markkuo.cscblebridge.service 2 | 3 | import android.app.* 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.graphics.Color 7 | import android.os.Binder 8 | import android.os.Build 9 | import android.os.IBinder 10 | import androidx.annotation.RequiresApi 11 | import androidx.core.app.NotificationCompat 12 | import idv.markkuo.cscblebridge.LaunchActivity 13 | import idv.markkuo.cscblebridge.R 14 | import idv.markkuo.cscblebridge.service.ant.AntDevice 15 | import idv.markkuo.cscblebridge.service.ble.BleServiceType 16 | 17 | class MainService : Service() { 18 | 19 | companion object { 20 | private const val CHANNEL_DEFAULT_IMPORTANCE = "csc_ble_channel" 21 | private const val MAIN_CHANNEL_NAME = "CscService" 22 | private const val ONGOING_NOTIFICATION_ID = 9999 23 | private const val STOP_SELF_ACTION = "stop_self" 24 | } 25 | 26 | interface MainServiceListener { 27 | fun searching(isSearching: Boolean) 28 | fun onDevicesUpdated(devices: List, selectedDevices: Map>) 29 | } 30 | 31 | private val listeners = ArrayList() 32 | 33 | private val bridge = AntToBleBridge() 34 | val isSearching: Boolean 35 | get() = bridge.isSearching 36 | 37 | override fun onCreate() { 38 | super.onCreate() 39 | startServiceInForeground() 40 | bridge.startup(this) { 41 | val newDevices = bridge.antDevices.values.toList() 42 | listeners.forEach { 43 | it.onDevicesUpdated(newDevices, bridge.selectedDevices) 44 | } 45 | } 46 | listeners.forEach { it.searching(true) } 47 | } 48 | 49 | 50 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 51 | intent?.action?.let { 52 | if (it == STOP_SELF_ACTION) { 53 | stopSearching() 54 | } 55 | } 56 | return super.onStartCommand(intent, flags, startId) 57 | } 58 | 59 | private val binder: IBinder = LocalBinder() 60 | 61 | override fun onBind(intent: Intent?): IBinder { 62 | return binder 63 | } 64 | 65 | /** 66 | * Get the services for communicating with it 67 | */ 68 | inner class LocalBinder : Binder() { 69 | val service: MainService 70 | get() = this@MainService 71 | } 72 | 73 | @RequiresApi(Build.VERSION_CODES.O) 74 | private fun createNotificationChannel(channelId: String, channelName: String) { 75 | val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW) 76 | channel.lightColor = Color.BLUE 77 | channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC 78 | val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 79 | manager.createNotificationChannel(channel) 80 | } 81 | 82 | private fun startServiceInForeground() { 83 | val intent = Intent(this, MainService::class.java) 84 | intent.action = STOP_SELF_ACTION 85 | val stopPendingIntent = PendingIntent.getService(this, 0, intent, 0) 86 | val stopAction = NotificationCompat.Action(R.drawable.ic_baseline_stop_24, "Stop", stopPendingIntent) 87 | 88 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 89 | createNotificationChannel(CHANNEL_DEFAULT_IMPORTANCE, MAIN_CHANNEL_NAME) 90 | 91 | // Create the PendingIntent 92 | val notifyPendingIntent = PendingIntent.getActivity( 93 | this, 94 | 0, 95 | Intent(this.applicationContext, LaunchActivity::class.java), 96 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) 97 | 98 | // build a notification 99 | val notification: Notification = NotificationCompat.Builder(this, CHANNEL_DEFAULT_IMPORTANCE) 100 | .setContentTitle(getText(R.string.app_name)) 101 | .setContentText("Active") 102 | .setSmallIcon(R.drawable.ic_baseline_bluetooth_24) 103 | .setAutoCancel(false) 104 | .setContentIntent(notifyPendingIntent) 105 | .addAction(stopAction) 106 | .setTicker(getText(R.string.app_name)) 107 | .build() 108 | startForeground(ONGOING_NOTIFICATION_ID, notification) 109 | } else { 110 | val notification: Notification = NotificationCompat.Builder(this, CHANNEL_DEFAULT_IMPORTANCE) 111 | .setContentTitle(getString(R.string.app_name)) 112 | .setContentText("Active") 113 | .setSmallIcon(R.drawable.ic_baseline_bluetooth_24) 114 | .setPriority(NotificationCompat.PRIORITY_DEFAULT) 115 | .setAutoCancel(false) 116 | .addAction(stopAction) 117 | .build() 118 | startForeground(ONGOING_NOTIFICATION_ID, notification) 119 | } 120 | } 121 | 122 | private fun cleanup() { 123 | listeners.forEach { it.onDevicesUpdated(emptyList(), emptyMap()) } 124 | listeners.forEach { it.searching(false) } 125 | } 126 | 127 | fun stopSearching() { 128 | cleanup() 129 | bridge.stop() 130 | stopSelf() 131 | } 132 | 133 | fun addListener(serviceListener: MainServiceListener) { 134 | listeners.add(serviceListener) 135 | } 136 | 137 | fun removeListener(serviceListener: MainServiceListener) { 138 | listeners.remove(serviceListener) 139 | } 140 | 141 | fun getConnectedDevices(): HashMap { 142 | return bridge.antDevices 143 | } 144 | 145 | fun deviceSelected(antDevice: AntDevice) { 146 | bridge.deviceSelected(antDevice) 147 | } 148 | 149 | override fun onDestroy() { 150 | stopSearching() 151 | super.onDestroy() 152 | } 153 | } -------------------------------------------------------------------------------- /app/src/main/java/idv/markkuo/cscblebridge/service/ant/AntDevice.kt: -------------------------------------------------------------------------------- 1 | package idv.markkuo.cscblebridge.service.ant 2 | 3 | import idv.markkuo.cscblebridge.service.ble.BleServiceType 4 | 5 | sealed class AntDevice(val deviceId: Int, val deviceName: String, val typeName: String, val bleType: BleServiceType) { 6 | data class BsdDevice( 7 | private val id: Int, 8 | private val name: String, 9 | var lastSpeed: Float, 10 | var cumulativeWheelRevolution: Long, 11 | var lastWheelEventTime: Int, 12 | var lastSpeedTimestamp: Long 13 | ): AntDevice(id, name, "ANT+ Bike Speed", BleServiceType.CscService) { 14 | override fun getDataString(): String { 15 | return "Speed: $lastSpeed, RPM: $cumulativeWheelRevolution" 16 | } 17 | } 18 | 19 | data class BcDevice( 20 | private val id: Int, 21 | private val name: String, 22 | var cadence: Int, 23 | var cumulativeCrankRevolution: Long, 24 | var crankEventTime: Long, 25 | var cadenceTimestamp: Long 26 | ) : AntDevice(id, name, "ANT+ Bike Cadence", BleServiceType.CscService) { 27 | override fun getDataString(): String { 28 | return "Cadence: $cadence, Crank Revolution: $cumulativeCrankRevolution" 29 | } 30 | } 31 | 32 | data class SSDevice( 33 | private val id: Int, 34 | private val name: String, 35 | var ssDistance: Long, 36 | var ssDistanceTimestamp: Long, 37 | var ssSpeed: Float, 38 | var ssSpeedTimestamp: Long, 39 | var stridePerMinute: Long, 40 | var stridePerMinuteTimestamp: Long 41 | ) : AntDevice(id, name, "ANT+ Stride SDM", BleServiceType.RscService) { 42 | override fun getDataString(): String { 43 | return "Speed: $ssSpeed, Stride/Min: $stridePerMinute" 44 | } 45 | } 46 | 47 | data class HRDevice( 48 | private val id: Int, 49 | private val name: String, 50 | var hr: Int, 51 | var hrTimestamp: Long 52 | ) : AntDevice(id, name, "ANT+ Heart Rate", BleServiceType.HrService) { 53 | override fun getDataString(): String { 54 | return "Heart Rate: $hr" 55 | } 56 | } 57 | 58 | abstract fun getDataString(): String 59 | } -------------------------------------------------------------------------------- /app/src/main/java/idv/markkuo/cscblebridge/service/ant/AntDeviceConnector.kt: -------------------------------------------------------------------------------- 1 | package idv.markkuo.cscblebridge.service.ant 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import com.dsi.ant.plugins.antplus.pcc.defines.DeviceState 6 | import com.dsi.ant.plugins.antplus.pcc.defines.RequestAccessResult 7 | import com.dsi.ant.plugins.antplus.pccbase.AntPluginPcc 8 | import com.dsi.ant.plugins.antplus.pccbase.AntPluginPcc.IDeviceStateChangeReceiver 9 | import com.dsi.ant.plugins.antplus.pccbase.PccReleaseHandle 10 | import java.util.concurrent.ConcurrentHashMap 11 | 12 | abstract class AntDeviceConnector(private val context: Context, internal val listener: DeviceManagerListener) { 13 | 14 | interface DeviceManagerListener { 15 | fun onDeviceStateChanged(result: RequestAccessResult, deviceState: DeviceState) 16 | fun onDataUpdated(data: Data) 17 | fun onCombinedSensor(antDeviceConnector: AntDeviceConnector<*, *>, deviceId: Int) 18 | } 19 | 20 | companion object { 21 | private const val TAG = "AntDeviceManager" 22 | } 23 | 24 | private val devices = ConcurrentHashMap() 25 | 26 | private var releaseHandle: PccReleaseHandle? = null 27 | 28 | private var deviceStateChangedReceiver: IDeviceStateChangeReceiver = IDeviceStateChangeReceiver { 29 | Log.d(TAG, "Device State Changed ${it.name}") 30 | } 31 | 32 | private val resultReceiver = AntPluginPcc.IPluginAccessResultReceiver { 33 | pcc: T?, requestAccessResult: RequestAccessResult, deviceState: DeviceState -> 34 | when (requestAccessResult) { 35 | RequestAccessResult.SUCCESS -> { 36 | if (pcc != null) { 37 | Log.d(TAG, "${pcc.deviceName}: ${deviceState})") 38 | subscribeToEvents(pcc) 39 | } 40 | } 41 | RequestAccessResult.USER_CANCELLED -> { 42 | Log.d(TAG, "Ant Device Closed: $requestAccessResult") 43 | } 44 | else -> { 45 | Log.w(TAG, "Ant Device State changed: $deviceState, resultCode: $requestAccessResult") 46 | } 47 | } 48 | listener.onDeviceStateChanged(requestAccessResult, deviceState) 49 | } 50 | 51 | abstract fun requestAccess( 52 | context: Context, 53 | resultReceiver: AntPluginPcc.IPluginAccessResultReceiver, 54 | stateChangedReceiver: IDeviceStateChangeReceiver, 55 | deviceNumber: Int = 0 56 | ): PccReleaseHandle 57 | 58 | abstract fun subscribeToEvents(pcc: T) 59 | abstract fun init(deviceNumber: Int, deviceName: String): Data 60 | 61 | fun startSearch() { 62 | stopSearch() 63 | releaseHandle = requestAccess(context, resultReceiver, deviceStateChangedReceiver) 64 | } 65 | 66 | fun stopSearch() { 67 | releaseHandle?.close() 68 | devices.clear() 69 | } 70 | 71 | internal fun getDevice(pcc: T): Data { 72 | if (!devices.containsKey(pcc.antDeviceNumber)) { 73 | devices[pcc.antDeviceNumber] = init(pcc.antDeviceNumber, pcc.deviceName) 74 | } 75 | return devices[pcc.antDeviceNumber]!! 76 | } 77 | } -------------------------------------------------------------------------------- /app/src/main/java/idv/markkuo/cscblebridge/service/ant/BcConnector.kt: -------------------------------------------------------------------------------- 1 | package idv.markkuo.cscblebridge.service.ant 2 | 3 | import android.content.Context 4 | import com.dsi.ant.plugins.antplus.pcc.AntPlusBikeCadencePcc 5 | import com.dsi.ant.plugins.antplus.pccbase.AntPluginPcc 6 | import com.dsi.ant.plugins.antplus.pccbase.PccReleaseHandle 7 | 8 | 9 | class BcConnector(context: Context, listener: DeviceManagerListener, val isCombinedSensor: Boolean = false): AntDeviceConnector(context, listener) { 10 | override fun requestAccess(context: Context, resultReceiver: AntPluginPcc.IPluginAccessResultReceiver, stateChangedReceiver: AntPluginPcc.IDeviceStateChangeReceiver, deviceNumber: Int): PccReleaseHandle { 11 | return AntPlusBikeCadencePcc.requestAccess(context, deviceNumber, 0, false, resultReceiver, stateChangedReceiver) 12 | } 13 | 14 | override fun subscribeToEvents(pcc: AntPlusBikeCadencePcc) { 15 | pcc.subscribeCalculatedCadenceEvent { _, _, calculatedCadence -> 16 | val device = getDevice(pcc) 17 | device.cadence = calculatedCadence.toInt() 18 | listener.onDataUpdated(device) 19 | } 20 | 21 | pcc.subscribeRawCadenceDataEvent { estTimestamp, _, timestampOfLastEvent, cumulativeRevolutions -> 22 | val device = getDevice(pcc) 23 | device.cumulativeCrankRevolution = cumulativeRevolutions 24 | device.crankEventTime = (timestampOfLastEvent.toDouble() * 1024.0).toLong() 25 | device.cadenceTimestamp = estTimestamp 26 | listener.onDataUpdated(device) 27 | } 28 | 29 | 30 | if (pcc.isSpeedAndCadenceCombinedSensor && !isCombinedSensor) { 31 | listener.onCombinedSensor(this, pcc.antDeviceNumber) 32 | } 33 | } 34 | 35 | override fun init(deviceNumber: Int, deviceName: String): AntDevice.BcDevice { 36 | return AntDevice.BcDevice(deviceNumber, deviceName, 0, 0L, 0L, 0L) 37 | } 38 | } -------------------------------------------------------------------------------- /app/src/main/java/idv/markkuo/cscblebridge/service/ant/BsdConnector.kt: -------------------------------------------------------------------------------- 1 | package idv.markkuo.cscblebridge.service.ant 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import com.dsi.ant.plugins.antplus.pcc.AntPlusBikeSpeedDistancePcc 6 | import com.dsi.ant.plugins.antplus.pcc.AntPlusBikeSpeedDistancePcc.CalculatedSpeedReceiver 7 | import com.dsi.ant.plugins.antplus.pcc.defines.EventFlag 8 | import com.dsi.ant.plugins.antplus.pccbase.AntPluginPcc 9 | import com.dsi.ant.plugins.antplus.pccbase.PccReleaseHandle 10 | import java.math.BigDecimal 11 | import java.util.* 12 | 13 | class BsdConnector(context: Context, listener: DeviceManagerListener, val isCombinedSensor: Boolean = false): AntDeviceConnector(context, listener) { 14 | 15 | companion object { 16 | private const val TAG = "BsdConnector" 17 | private val circumference = BigDecimal("2.095") 18 | private val msToKmSRatio = BigDecimal("3.6") 19 | } 20 | 21 | override fun requestAccess(context: Context, 22 | resultReceiver: AntPluginPcc.IPluginAccessResultReceiver, 23 | stateChangedReceiver: AntPluginPcc.IDeviceStateChangeReceiver, 24 | deviceNumber: Int): PccReleaseHandle { 25 | return AntPlusBikeSpeedDistancePcc.requestAccess(context, deviceNumber, 0, false, resultReceiver, stateChangedReceiver) 26 | } 27 | 28 | override fun subscribeToEvents(pcc: AntPlusBikeSpeedDistancePcc) { 29 | pcc.subscribeCalculatedSpeedEvent(object : CalculatedSpeedReceiver(circumference) { 30 | override fun onNewCalculatedSpeed(estTimestamp: Long, 31 | eventFlags: EnumSet, calculatedSpeed: BigDecimal) { 32 | val device = getDevice(pcc) 33 | // convert m/s to km/h 34 | device.lastSpeed = calculatedSpeed.multiply(msToKmSRatio).toFloat() 35 | listener.onDataUpdated(device) 36 | } 37 | }) 38 | pcc.subscribeRawSpeedAndDistanceDataEvent { estTimestamp, _, timestampOfLastEvent, cumulativeRevolutions -> //estTimestamp - The estimated timestamp of when this event was triggered. Useful for correlating multiple events and determining when data was sent for more accurate data records. 39 | //eventFlags - Informational flags about the event. 40 | //timestampOfLastEvent - Sensor reported time counter value of last distance or speed computation (up to 1/200s accuracy). Units: s. Rollover: Every ~46 quadrillion s (~1.5 billion years). 41 | //cumulativeRevolutions - Total number of revolutions since the sensor was first connected. Note: If the subscriber is not the first PCC connected to the device the accumulation will probably already be at a value greater than 0 and the subscriber should save the first received value as a relative zero for itself. Units: revolutions. Rollover: Every ~9 quintillion revolutions. 42 | Log.v(TAG, "=> BSD: Cumulative revolution: $cumulativeRevolutions, lastEventTime: $timestampOfLastEvent") 43 | val device = getDevice(pcc) 44 | device.cumulativeWheelRevolution = cumulativeRevolutions 45 | device.lastWheelEventTime = (timestampOfLastEvent.toDouble() * 1024.0).toInt() 46 | device.lastSpeedTimestamp = estTimestamp 47 | listener.onDataUpdated(device) 48 | } 49 | 50 | if (pcc.isSpeedAndCadenceCombinedSensor && !isCombinedSensor) { 51 | listener.onCombinedSensor(this, pcc.antDeviceNumber) 52 | } 53 | } 54 | 55 | override fun init(deviceNumber: Int, deviceName: String): AntDevice.BsdDevice { 56 | return AntDevice.BsdDevice(deviceNumber, deviceName, 0f, 0L, 0, 0L) 57 | } 58 | 59 | } -------------------------------------------------------------------------------- /app/src/main/java/idv/markkuo/cscblebridge/service/ant/HRConnector.kt: -------------------------------------------------------------------------------- 1 | package idv.markkuo.cscblebridge.service.ant 2 | 3 | import android.content.Context 4 | import com.dsi.ant.plugins.antplus.pcc.AntPlusHeartRatePcc 5 | import com.dsi.ant.plugins.antplus.pccbase.AntPluginPcc 6 | import com.dsi.ant.plugins.antplus.pccbase.PccReleaseHandle 7 | 8 | class HRConnector(context: Context, listener: DeviceManagerListener): AntDeviceConnector(context, listener) { 9 | override fun requestAccess(context: Context, resultReceiver: AntPluginPcc.IPluginAccessResultReceiver, stateChangedReceiver: AntPluginPcc.IDeviceStateChangeReceiver, deviceNumber: Int): PccReleaseHandle { 10 | return AntPlusHeartRatePcc.requestAccess(context, deviceNumber, 0, resultReceiver, stateChangedReceiver) 11 | } 12 | 13 | override fun subscribeToEvents(pcc: AntPlusHeartRatePcc) { 14 | pcc.subscribeHeartRateDataEvent { estTimestamp, _, computedHeartRate, _, _, _ -> 15 | val device = getDevice(pcc) 16 | device.hr = computedHeartRate 17 | device.hrTimestamp = estTimestamp 18 | listener.onDataUpdated(device) 19 | } 20 | } 21 | 22 | override fun init(deviceNumber: Int, deviceName: String): AntDevice.HRDevice { 23 | return AntDevice.HRDevice(deviceNumber, deviceName, 0, 0L) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/idv/markkuo/cscblebridge/service/ant/SSConnector.kt: -------------------------------------------------------------------------------- 1 | package idv.markkuo.cscblebridge.service.ant 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import android.util.Pair 6 | import com.dsi.ant.plugins.antplus.pcc.AntPlusStrideSdmPcc 7 | import com.dsi.ant.plugins.antplus.pcc.AntPlusStrideSdmPcc.* 8 | import com.dsi.ant.plugins.antplus.pcc.defines.EventFlag 9 | import com.dsi.ant.plugins.antplus.pccbase.AntPluginPcc 10 | import com.dsi.ant.plugins.antplus.pccbase.PccReleaseHandle 11 | import java.util.* 12 | import java.util.concurrent.Semaphore 13 | 14 | class SSConnector(context: Context, listener: DeviceManagerListener): AntDeviceConnector(context, listener) { 15 | 16 | companion object { 17 | private const val TAG = "SSConnector" 18 | } 19 | 20 | override fun requestAccess( 21 | context: Context, 22 | resultReceiver: AntPluginPcc.IPluginAccessResultReceiver, 23 | stateChangedReceiver: AntPluginPcc.IDeviceStateChangeReceiver, 24 | deviceNumber: Int): PccReleaseHandle { 25 | return requestAccess(context, deviceNumber, 0, resultReceiver, stateChangedReceiver) 26 | } 27 | 28 | override fun subscribeToEvents(pcc: AntPlusStrideSdmPcc) { 29 | // https://www.thisisant.com/developer/ant-plus/device-profiles#528_tab 30 | pcc.subscribeStrideCountEvent(object : IStrideCountReceiver { 31 | private val TEN_SECONDS_IN_MS = 10000 32 | private val FALLBACK_MAX_LIST_SIZE = 500 33 | private val ONE_MINUTE_IN_MS = 60000f 34 | private val strideList = LinkedList>() 35 | private val lock = Semaphore(1) 36 | override fun onNewStrideCount(estTimestamp: Long, eventFlags: EnumSet, cumulativeStrides: Long) { 37 | Thread { 38 | try { 39 | lock.acquire() 40 | // Calculate number of strides per minute, updates happen around every 500 ms, this number 41 | // may be off by that amount but it isn't too significant 42 | strideList.addFirst(Pair(estTimestamp, cumulativeStrides)) 43 | var strideCount: Long = 0 44 | var valueFound = false 45 | var i = 0 46 | for (p in strideList) { 47 | // Cadence over the last 10 seconds 48 | if (estTimestamp - p.first >= TEN_SECONDS_IN_MS) { 49 | valueFound = true 50 | strideCount = calculateStepsPerMin(estTimestamp, cumulativeStrides, p) 51 | break 52 | } else if (i + 1 == strideList.size) { 53 | // No value was found yet, it has not been 10 seconds. Give an early rough estimate 54 | strideCount = calculateStepsPerMin(estTimestamp, cumulativeStrides, p) 55 | } 56 | i++ 57 | } 58 | while (valueFound && strideList.size >= i + 1 || strideList.size > FALLBACK_MAX_LIST_SIZE) { 59 | strideList.removeLast() 60 | } 61 | val device = getDevice(pcc) 62 | device.stridePerMinute = strideCount 63 | device.stridePerMinuteTimestamp = estTimestamp 64 | lock.release() 65 | listener.onDataUpdated(device) 66 | } catch (e: InterruptedException) { 67 | Log.e(TAG, "Unable to acquire lock to update running cadence", e) 68 | } 69 | }.start() 70 | } 71 | 72 | private fun calculateStepsPerMin(estTimestamp: Long, cumulativeStrides: Long, p: Pair): Long { 73 | val elapsedTimeMs = estTimestamp - p.first.toFloat() 74 | return if (elapsedTimeMs == 0f) { 75 | 0 76 | } else ((cumulativeStrides - p.second) * (ONE_MINUTE_IN_MS / elapsedTimeMs)).toLong() 77 | } 78 | }) 79 | 80 | pcc.subscribeDistanceEvent { estTimestamp, _, distance -> 81 | val device = getDevice(pcc) 82 | device.ssDistance = distance.toLong() 83 | device.ssDistanceTimestamp = estTimestamp 84 | listener.onDataUpdated(device) 85 | } 86 | 87 | pcc.subscribeInstantaneousSpeedEvent { estTimestamp, _, instantaneousSpeed -> 88 | val device = getDevice(pcc) 89 | device.ssSpeed = instantaneousSpeed.toFloat() 90 | device.ssSpeedTimestamp = estTimestamp 91 | listener.onDataUpdated(device) 92 | } 93 | } 94 | 95 | override fun init(deviceNumber: Int, deviceName: String): AntDevice.SSDevice { 96 | return AntDevice.SSDevice(deviceNumber, deviceName, 0L, 0L, 0f, 0L, 0L, 0L) 97 | } 98 | 99 | } -------------------------------------------------------------------------------- /app/src/main/java/idv/markkuo/cscblebridge/service/ble/BleServer.kt: -------------------------------------------------------------------------------- 1 | package idv.markkuo.cscblebridge.service.ble 2 | 3 | import android.bluetooth.* 4 | import android.bluetooth.le.AdvertiseCallback 5 | import android.bluetooth.le.AdvertiseData 6 | import android.bluetooth.le.AdvertiseSettings 7 | import android.bluetooth.le.BluetoothLeAdvertiser 8 | import android.content.BroadcastReceiver 9 | import android.content.Context 10 | import android.content.Intent 11 | import android.content.IntentFilter 12 | import android.content.pm.PackageManager 13 | import android.os.ParcelUuid 14 | import android.util.Log 15 | import idv.markkuo.cscblebridge.service.ant.AntDevice 16 | import java.util.* 17 | import java.util.concurrent.Semaphore 18 | import kotlin.collections.ArrayList 19 | import kotlin.collections.HashMap 20 | import kotlin.collections.HashSet 21 | 22 | class BleServer { 23 | companion object { 24 | private const val TAG = "BleServer" 25 | private const val UPDATE_INTERVAL_MS = 1000L 26 | } 27 | private var bluetoothManager: BluetoothManager? = null 28 | private var context: Context? = null 29 | private var server: BluetoothGattServer? = null 30 | private var timer: Timer? = null 31 | private val registeredDevices: HashSet = HashSet() 32 | private var bluetoothLeAdvertiser: BluetoothLeAdvertiser? = null 33 | private val antData = hashMapOf>() 34 | private val servicesToCreate = ArrayList(BleServiceType.serviceTypes) 35 | private val mutex = Semaphore(1) 36 | var selectedDevices = HashMap>() 37 | 38 | fun startServer(context: Context) { 39 | this.context = context 40 | bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager? 41 | val bluetoothAdapter: BluetoothAdapter = bluetoothManager?.adapter 42 | ?: throw UnsupportedOperationException("Bluetooth adapter is not supported") 43 | if (!checkBluetoothSupport(bluetoothAdapter, context.packageManager)) { 44 | throw UnsupportedOperationException("Bluetooth LE isn't supported") 45 | } 46 | 47 | // Register for system Bluetooth events 48 | val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) 49 | context.registerReceiver(bluetoothReceiver, filter) 50 | 51 | if (!bluetoothAdapter.isEnabled) { 52 | Log.d(TAG, "Bluetooth is currently disabled...enabling") 53 | bluetoothAdapter.enable() 54 | } else { 55 | Log.d(TAG, "Bluetooth enabled...starting services") 56 | startAdvertising() 57 | startupInternal(context) 58 | } 59 | } 60 | 61 | fun updateData(serviceType: BleServiceType, antDevice: AntDevice) { 62 | antData[serviceType]?.let { devices -> 63 | val alreadyExists: AntDevice? = devices.firstOrNull { it.deviceId == antDevice.deviceId } 64 | 65 | if (alreadyExists != null) { 66 | devices.remove(alreadyExists) 67 | } 68 | devices.add(antDevice) 69 | 70 | return@updateData 71 | } 72 | 73 | antData[serviceType] = arrayListOf(antDevice) 74 | } 75 | 76 | private fun startupInternal(context: Context) { 77 | bluetoothManager = bluetoothManager ?: context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager? 78 | server = bluetoothManager?.openGattServer(context, gattServerCallback) 79 | ?: throw UnsupportedOperationException("Bluetooth manager could not be created") 80 | 81 | 82 | createNextService() 83 | 84 | timer?.cancel() 85 | timer = Timer().apply { 86 | schedule(object : TimerTask() { 87 | override fun run() { 88 | notifyRegisteredDevices() 89 | } 90 | }, UPDATE_INTERVAL_MS, UPDATE_INTERVAL_MS) 91 | } 92 | } 93 | 94 | private fun createNextService() { 95 | mutex.acquire() 96 | if (servicesToCreate.isNotEmpty()) { 97 | createService(servicesToCreate[0]) 98 | servicesToCreate.removeFirst() 99 | } 100 | mutex.release() 101 | } 102 | 103 | fun notifyRegisteredDevices() { 104 | Log.i(TAG, "Notifying ${registeredDevices.size} registered devices") 105 | registeredDevices.forEach { device -> 106 | BleServiceType.serviceTypes.forEach { bleService -> 107 | val service = server?.getService(bleService.serviceId) 108 | if (service != null) { 109 | val antDevices = antData[bleService] 110 | val selectedAntDevices = antDevices?.filter { selectedDevices[bleService]?.contains(it.deviceId) ?: false } 111 | if (selectedAntDevices != null && selectedAntDevices.isNotEmpty()) { 112 | Log.i(TAG, "Notifying ${device.address}(Name:${device.name}) for service ${service.uuid}") 113 | val data = bleService.getBleData(selectedAntDevices) 114 | val measurementCharacteristic: BluetoothGattCharacteristic = service 115 | .getCharacteristic(bleService.measurement) 116 | if (!measurementCharacteristic.setValue(data)) { 117 | Log.w(TAG, "${bleService.measurement} Measurement data isn't set properly!") 118 | } 119 | // false is used to send a notification 120 | server?.notifyCharacteristicChanged(device, measurementCharacteristic, false) 121 | } else { 122 | Log.v(TAG, "Not notifying anything for service ${service.uuid}: no ANT+ device") 123 | } 124 | } else { 125 | Log.v(TAG, "Service ${bleService.serviceId} was not found as an installed service") 126 | } 127 | } 128 | 129 | } 130 | } 131 | 132 | fun stopServer() { 133 | this.context?.unregisterReceiver(bluetoothReceiver) 134 | server?.close() 135 | timer?.cancel() 136 | timer = null 137 | 138 | context = null 139 | } 140 | 141 | /** 142 | * Begin advertising over Bluetooth that this device is connectable 143 | */ 144 | private fun startAdvertising() { 145 | val bluetoothAdapter: BluetoothAdapter? = bluetoothManager?.adapter 146 | if (bluetoothAdapter == null) { 147 | Log.e(TAG, "Failed to create bluetooth adapter") 148 | return 149 | } 150 | bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser 151 | bluetoothLeAdvertiser?.let { it -> 152 | val settings = AdvertiseSettings.Builder() 153 | .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) 154 | .setConnectable(true) 155 | .setTimeout(0) 156 | .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) 157 | .build() 158 | val dataBuilder = AdvertiseData.Builder().setIncludeDeviceName(true).setIncludeTxPowerLevel(true) 159 | BleServiceType.serviceTypes.forEach { objectInstance -> 160 | dataBuilder.addServiceUuid(ParcelUuid(objectInstance.serviceId)) 161 | } 162 | it.startAdvertising(settings, dataBuilder.build(), mAdvertiseCallback) 163 | } ?: Log.w(TAG, "Failed to create advertiser") 164 | } 165 | 166 | /** 167 | * Callback to receive information about the advertisement process 168 | */ 169 | private val mAdvertiseCallback: AdvertiseCallback = object : AdvertiseCallback() { 170 | override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { 171 | Log.i(TAG, "LE Advertise Started: $settingsInEffect") 172 | } 173 | 174 | override fun onStartFailure(errorCode: Int) { 175 | Log.w(TAG, "LE Advertise Failed: $errorCode") 176 | } 177 | } 178 | 179 | /** 180 | * Stop Bluetooth advertisements 181 | */ 182 | private fun stopAdvertising() { 183 | if (bluetoothLeAdvertiser == null) return 184 | bluetoothLeAdvertiser?.stopAdvertising(mAdvertiseCallback) 185 | } 186 | 187 | private fun checkBluetoothSupport(bluetoothAdapter: BluetoothAdapter?, packageManager: PackageManager): Boolean { 188 | return !(bluetoothAdapter == null || !packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) 189 | } 190 | 191 | private fun createService(type: BleServiceType) { 192 | BluetoothGattService(type.serviceId, BluetoothGattService.SERVICE_TYPE_PRIMARY).also { 193 | val measurement = BluetoothGattCharacteristic(type.measurement, 194 | BluetoothGattCharacteristic.PROPERTY_NOTIFY, 195 | BluetoothGattCharacteristic.PERMISSION_READ) 196 | val configDescriptor = BluetoothGattDescriptor(BleServiceType.CLIENT_CONFIG, 197 | BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE) 198 | measurement.addDescriptor(configDescriptor) 199 | it.addCharacteristic(measurement) 200 | 201 | if (type.feature != null) { 202 | val feature = BluetoothGattCharacteristic(type.feature, 203 | BluetoothGattCharacteristic.PROPERTY_READ, 204 | BluetoothGattCharacteristic.PERMISSION_READ) 205 | it.addCharacteristic(feature) 206 | } 207 | server?.addService(it) ?: throw IllegalStateException("Server must be started before adding a service") 208 | } 209 | } 210 | 211 | private val bluetoothReceiver: BroadcastReceiver = object : BroadcastReceiver() { 212 | override fun onReceive(context: Context, intent: Intent) { 213 | when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.STATE_OFF)) { 214 | BluetoothAdapter.STATE_ON -> { 215 | startAdvertising() 216 | startupInternal(context) 217 | } 218 | BluetoothAdapter.STATE_OFF -> { 219 | stopServer() 220 | stopAdvertising() 221 | } 222 | } 223 | } 224 | } 225 | 226 | private val gattServerCallback: BluetoothGattServerCallback = object : BluetoothGattServerCallback() { 227 | override fun onServiceAdded(status: Int, service: BluetoothGattService) { 228 | Log.i(TAG, "onServiceAdded(): status:$status, service:$service") 229 | createNextService() 230 | } 231 | 232 | override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) { 233 | if (registeredDevices.contains(device)) { 234 | if (newState == BluetoothProfile.STATE_DISCONNECTED) { 235 | Log.i(TAG, "BluetoothDevice DISCONNECTED: " + device.name + " [" + device.address + "]") 236 | //Remove device from any active subscriptions 237 | registeredDevices.remove(device) 238 | } else { 239 | Log.i(TAG, "onConnectionStateChange() status:$status->$newState, device$device") 240 | } 241 | } 242 | } 243 | 244 | override fun onNotificationSent(device: BluetoothDevice, status: Int) { 245 | Log.v(TAG, "onNotificationSent() device: ${device.name} result:$status") 246 | } 247 | 248 | override fun onMtuChanged(device: BluetoothDevice, mtu: Int) { 249 | Log.d(TAG, "onMtuChanged:$device =>$mtu") 250 | } 251 | 252 | override fun onCharacteristicReadRequest(device: BluetoothDevice, requestId: Int, offset: Int, 253 | characteristic: BluetoothGattCharacteristic) { 254 | 255 | val uuid = characteristic.uuid 256 | val feature = BleServiceType.serviceTypes.firstOrNull { 257 | it.feature == uuid 258 | } 259 | val measurement = BleServiceType.serviceTypes.firstOrNull { 260 | it.measurement == uuid 261 | } 262 | 263 | if (feature != null || measurement != null) { 264 | server!!.sendResponse(device, 265 | requestId, 266 | BluetoothGatt.GATT_SUCCESS, 267 | 0, 268 | feature?.getSupportedFeatures()) 269 | } else { 270 | server!!.sendResponse(device, 271 | requestId, 272 | BluetoothGatt.GATT_FAILURE, 273 | 0, 274 | null) 275 | } 276 | } 277 | 278 | override fun onDescriptorReadRequest(device: BluetoothDevice, requestId: Int, offset: Int, 279 | descriptor: BluetoothGattDescriptor) { 280 | if (BleServiceType.CLIENT_CONFIG == descriptor.uuid) { 281 | Log.d(TAG, "Config descriptor read") 282 | val returnValue: ByteArray = if (registeredDevices.contains(device)) { 283 | BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE 284 | } else { 285 | BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE 286 | } 287 | server!!.sendResponse(device, 288 | requestId, 289 | BluetoothGatt.GATT_SUCCESS, 290 | offset, 291 | returnValue) 292 | } else { 293 | Log.w(TAG, "Unknown descriptor read request") 294 | server!!.sendResponse(device, 295 | requestId, 296 | BluetoothGatt.GATT_FAILURE, 297 | offset, 298 | null) 299 | } 300 | } 301 | 302 | override fun onDescriptorWriteRequest(device: BluetoothDevice, requestId: Int, 303 | descriptor: BluetoothGattDescriptor, 304 | preparedWrite: Boolean, responseNeeded: Boolean, 305 | offset: Int, value: ByteArray) { 306 | if (BleServiceType.CLIENT_CONFIG == descriptor.uuid) { 307 | if (Arrays.equals(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE, value)) { 308 | Log.d(TAG, "Subscribe device to notifications: $device") 309 | registeredDevices.add(device) 310 | } else if (Arrays.equals(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE, value)) { 311 | Log.d(TAG, "Unsubscribe device from notifications: $device") 312 | registeredDevices.remove(device) 313 | } 314 | if (responseNeeded) { 315 | server!!.sendResponse(device, 316 | requestId, 317 | BluetoothGatt.GATT_SUCCESS, 318 | 0, 319 | null) 320 | } 321 | } else { 322 | Log.w(TAG, "Unknown descriptor write request") 323 | if (responseNeeded) { 324 | server!!.sendResponse(device, 325 | requestId, 326 | BluetoothGatt.GATT_FAILURE, 327 | 0, 328 | null) 329 | } 330 | } 331 | } 332 | } 333 | } -------------------------------------------------------------------------------- /app/src/main/java/idv/markkuo/cscblebridge/service/ble/BleServiceType.kt: -------------------------------------------------------------------------------- 1 | package idv.markkuo.cscblebridge.service.ble 2 | 3 | import idv.markkuo.cscblebridge.service.ant.AntDevice 4 | import java.util.* 5 | import kotlin.experimental.and 6 | import kotlin.experimental.or 7 | 8 | /** 9 | * Type of bluetooth service 10 | * 11 | * NOTE: Make sure these are objects since reflection is used 12 | */ 13 | sealed class BleServiceType(val serviceId: UUID, val measurement: UUID, val feature: UUID?) { 14 | companion object { 15 | val serviceTypes = listOf(CscService, RscService, HrService) 16 | var CLIENT_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") 17 | } 18 | 19 | object CscService: BleServiceType( 20 | // https://www.bluetooth.com/specifications/gatt/services/ 21 | /** Cycling Speed and Cadence */ 22 | UUID.fromString("00001816-0000-1000-8000-00805f9b34fb"), 23 | // https://www.bluetooth.com/specifications/gatt/characteristics/ 24 | /** Mandatory Characteristic: CSC Measurement */ 25 | UUID.fromString("00002a5b-0000-1000-8000-00805f9b34fb"), 26 | /** Mandatory Characteristic: CSC Feature */ 27 | UUID.fromString("00002a5c-0000-1000-8000-00805f9b34fb") 28 | ) { 29 | /** supported CSC Feature bit: Speed sensor */ 30 | private const val CSC_FEATURE_WHEEL_REV: Byte = 0x1 31 | /** supported CSC Feature bit: Cadence sensor */ 32 | private const val CSC_FEATURE_CRANK_REV: Byte = 0x2 33 | 34 | private var currentFeature = CSC_FEATURE_WHEEL_REV or CSC_FEATURE_CRANK_REV 35 | 36 | override fun getSupportedFeatures(): ByteArray { 37 | val data = ByteArray(2) 38 | // always leave the second byte 0 39 | data[0] = currentFeature 40 | return data 41 | } 42 | 43 | override fun getBleData(antDevices: List): ByteArray { 44 | if (antDevices.size > 2 || antDevices.isEmpty()) { 45 | throw IllegalArgumentException("2 ANT+ devices (speed vs cadence) can be paired, Or 1 ANT+ device. But found ${antDevices.size}") 46 | } 47 | 48 | val bsdDevice = antDevices.firstOrNull { it is AntDevice.BsdDevice } as AntDevice.BsdDevice? 49 | val bcDevice = (antDevices.firstOrNull { it is AntDevice.BcDevice }) as AntDevice.BcDevice? 50 | 51 | if (bsdDevice == null && bcDevice == null) { 52 | throw IllegalArgumentException("Found devices which were not bsd devices") 53 | } 54 | val bsdFeature = if (bsdDevice != null) { 55 | CSC_FEATURE_WHEEL_REV 56 | } else { 57 | 0 58 | } 59 | val bcFeature = if (bcDevice != null) { 60 | CSC_FEATURE_CRANK_REV 61 | } else { 62 | 0 63 | } 64 | 65 | currentFeature = bsdFeature or bcFeature 66 | 67 | val data: MutableList = ArrayList() 68 | data.add((currentFeature and 0x3)) // only preserve bit 0 and 1 69 | 70 | if ((currentFeature and CSC_FEATURE_WHEEL_REV).toInt() == CSC_FEATURE_WHEEL_REV.toInt()) { 71 | // cumulative wheel revolutions (uint32), only take the last 4 bytes 72 | bsdDevice?.let { 73 | data.add(it.cumulativeWheelRevolution.toByte()) 74 | data.add((it.cumulativeWheelRevolution shr java.lang.Byte.SIZE).toByte()) 75 | data.add((it.cumulativeWheelRevolution shr java.lang.Byte.SIZE * 2).toByte()) 76 | data.add((it.cumulativeWheelRevolution shr java.lang.Byte.SIZE * 3).toByte()) 77 | 78 | // Last Wheel Event Time (uint16), unit is 1/1024s, only take the last 2 bytes 79 | data.add(it.lastWheelEventTime.toByte()) 80 | data.add((it.lastWheelEventTime shr java.lang.Byte.SIZE).toByte()) 81 | } 82 | } 83 | if ((currentFeature and CSC_FEATURE_CRANK_REV).toInt() == CSC_FEATURE_CRANK_REV.toInt()) { 84 | bcDevice?.let { 85 | // Cumulative Crank Revolutions (uint16) 86 | data.add(it.cumulativeCrankRevolution.toByte()) 87 | data.add((it.cumulativeCrankRevolution shr java.lang.Byte.SIZE).toByte()) 88 | 89 | // Last Crank Event Time (uint16) uint is 1/1024s 90 | data.add(it.crankEventTime.toByte()) 91 | data.add((it.crankEventTime shr java.lang.Byte.SIZE).toByte()) 92 | } 93 | } 94 | 95 | // convert to primitive byte array 96 | val byteArray = ByteArray(data.size) 97 | for (i in data.indices) { 98 | byteArray[i] = data[i] 99 | } 100 | return byteArray 101 | } 102 | } 103 | 104 | object HrService: BleServiceType( 105 | UUID.fromString("0000180D-0000-1000-8000-00805f9b34fb"), 106 | UUID.fromString("00002A37-0000-1000-8000-00805f9b34fb"), 107 | null 108 | ) { 109 | override fun getSupportedFeatures(): ByteArray? { 110 | return null 111 | } 112 | 113 | override fun getBleData(antDevices: List): ByteArray { 114 | if (antDevices.size > 1 || antDevices.isEmpty()) { 115 | throw IllegalArgumentException("Only one HR ANT+ device can be selected at a time") 116 | } 117 | val antDevice = antDevices.first() 118 | if (antDevice !is AntDevice.HRDevice) { 119 | throw IllegalArgumentException("Unable to get BLE Data for HR device with $antDevice") 120 | } 121 | 122 | // https://www.bluetooth.com/wp-content/uploads/Sitecore-Media-Library/Gatt/Xml/Characteristics/org.bluetooth.characteristic.heart_rate_measurement.xml 123 | val data: MutableList = ArrayList() 124 | 125 | // Add the Flags, for our use they're all 0s 126 | data.add(0.toByte()) 127 | data.add(antDevice.hr.toByte()) 128 | 129 | // convert to primitive byte array 130 | val byteArray = ByteArray(data.size) 131 | for (i in data.indices) { 132 | byteArray[i] = data[i] 133 | } 134 | return byteArray 135 | } 136 | } 137 | 138 | object RscService: BleServiceType( 139 | UUID.fromString("00001814-0000-1000-8000-00805f9b34fb"), 140 | UUID.fromString("00002A53-0000-1000-8000-00805f9b34fb"), 141 | UUID.fromString("00002A54-0000-1000-8000-00805f9b34fb") 142 | ) { 143 | private const val RSC_NO_FEATURES: Byte = 0 144 | override fun getSupportedFeatures(): ByteArray { 145 | val data = ByteArray(1) 146 | data[0] = RSC_NO_FEATURES 147 | return data 148 | } 149 | 150 | // https://www.bluetooth.com/wp-content/uploads/Sitecore-Media-Library/Gatt/Xml/Characteristics/org.bluetooth.characteristic.rsc_measurement.xml 151 | override fun getBleData(antDevices: List): ByteArray { 152 | if (antDevices.size > 1 || antDevices.isEmpty()) { 153 | throw IllegalArgumentException("Only one HR ANT+ device can be selected at a time, found ${antDevices.size}") 154 | } 155 | val antDevice = antDevices.first() 156 | if (antDevice !is AntDevice.SSDevice) { 157 | throw IllegalArgumentException("Unable to get BLE Data for RSC device with $antDevice") 158 | } 159 | 160 | val data: MutableList = ArrayList() 161 | // Instantaneous stride length, total distance and walking or running could be calculated, but are not supported for now 162 | data.add(0.toByte()) 163 | 164 | // Instantaneous Speed; Unit is in m/s with a resolution of 1/256 s (uint16) 165 | val wholeNumber = antDevice.ssSpeed.toInt() 166 | val decimalPlaces = binaryDecimalToByte(antDevice.ssSpeed, wholeNumber) 167 | data.add(decimalPlaces) 168 | data.add(wholeNumber.toByte()) 169 | 170 | // Instantaneous Cadence, Unit is in 1/minute (or RPM) with a resolutions of 1 1/min (or 1 RPM) (uint8) 171 | data.add(antDevice.stridePerMinute.toByte()) 172 | 173 | // convert to primitive byte array 174 | val byteArray = ByteArray(data.size) 175 | for (i in data.indices) { 176 | byteArray[i] = data[i] 177 | } 178 | return byteArray 179 | } 180 | 181 | private fun binaryDecimalToByte(lastSSSpeed: Float, wholeNumber: Int): Byte { 182 | var number: Double 183 | var fraction: Double 184 | var integralPart: Int 185 | var b = 0 186 | fraction = (lastSSSpeed - wholeNumber).toDouble() 187 | for (i in 7 downTo 0) { 188 | integralPart = (fraction * 2).toInt() 189 | if (integralPart == 1) { 190 | b = b or 1 shl i 191 | } 192 | number = fraction * 2 193 | fraction = number - integralPart 194 | } 195 | return b.toByte() 196 | } 197 | } 198 | 199 | abstract fun getSupportedFeatures(): ByteArray? 200 | abstract fun getBleData(antDevices: List): ByteArray? 201 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_bluetooth_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_stop_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_launch.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/layout/ant_list_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 12 | 13 | 19 | 20 | 25 | 26 | 31 | 32 | 37 | 38 | 39 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /app/src/main/res/layout/broadcast_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 |