├── .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 |
24 |
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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
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 |  | 
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 |
17 |
18 |
25 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
15 |
16 |
24 |
25 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/starryalley/CSC_BLE_Bridge/bc153f2e7dd269246707dfc554bdab7c1fca4cbb/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/starryalley/CSC_BLE_Bridge/bc153f2e7dd269246707dfc554bdab7c1fca4cbb/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/starryalley/CSC_BLE_Bridge/bc153f2e7dd269246707dfc554bdab7c1fca4cbb/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/starryalley/CSC_BLE_Bridge/bc153f2e7dd269246707dfc554bdab7c1fca4cbb/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/starryalley/CSC_BLE_Bridge/bc153f2e7dd269246707dfc554bdab7c1fca4cbb/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/starryalley/CSC_BLE_Bridge/bc153f2e7dd269246707dfc554bdab7c1fca4cbb/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/starryalley/CSC_BLE_Bridge/bc153f2e7dd269246707dfc554bdab7c1fca4cbb/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/starryalley/CSC_BLE_Bridge/bc153f2e7dd269246707dfc554bdab7c1fca4cbb/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/starryalley/CSC_BLE_Bridge/bc153f2e7dd269246707dfc554bdab7c1fca4cbb/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/starryalley/CSC_BLE_Bridge/bc153f2e7dd269246707dfc554bdab7c1fca4cbb/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #6200EE
4 | #3700B3
5 | #03DAC5
6 |
7 |
--------------------------------------------------------------------------------
/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 5dp
4 | 10dp
5 | 10dp
6 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | CSC BLE Bridge
3 | Start BLE Bridging
4 | Stop BLE Bridging
5 | ---
6 | Current Speed on 700x23C tire (km/h)
7 | Bike Speed Sensor
8 | Bike Cadence Sensor
9 | HR Sensor
10 | Run Sensor
11 | TimeStamp
12 | Current Cadence (rpm)
13 | Heart rate (bpm)
14 | Run Speed (km/h)
15 | Run Cadence (spm)
16 | Bluetooth connected
17 | Broadcast
18 | 80 bpm
19 | Ant+ Heart Rate
20 | 1234
21 |
22 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 |
3 | buildscript {
4 | ext.kotlin_version = '1.7.10'
5 |
6 | repositories {
7 | google()
8 | mavenCentral()
9 |
10 | }
11 | dependencies {
12 | classpath 'com.android.tools.build:gradle:7.3.0'
13 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
14 |
15 |
16 | // NOTE: Do not place your application dependencies here; they belong
17 | // in the individual module build.gradle files
18 | }
19 | }
20 |
21 | allprojects {
22 | repositories {
23 | google()
24 | mavenCentral()
25 |
26 | }
27 | }
28 |
29 | task clean(type: Delete) {
30 | delete rootProject.buildDir
31 | }
32 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx1536m
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Automatically convert third-party libraries to use AndroidX
19 | android.enableJetifier=true
20 |
21 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/starryalley/CSC_BLE_Bridge/bc153f2e7dd269246707dfc554bdab7c1fca4cbb/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Mon Jan 25 10:24:30 AEDT 2021
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Attempt to set APP_HOME
10 | # Resolve links: $0 may be a link
11 | PRG="$0"
12 | # Need this for relative symlinks.
13 | while [ -h "$PRG" ] ; do
14 | ls=`ls -ld "$PRG"`
15 | link=`expr "$ls" : '.*-> \(.*\)$'`
16 | if expr "$link" : '/.*' > /dev/null; then
17 | PRG="$link"
18 | else
19 | PRG=`dirname "$PRG"`"/$link"
20 | fi
21 | done
22 | SAVED="`pwd`"
23 | cd "`dirname \"$PRG\"`/" >/dev/null
24 | APP_HOME="`pwd -P`"
25 | cd "$SAVED" >/dev/null
26 |
27 | APP_NAME="Gradle"
28 | APP_BASE_NAME=`basename "$0"`
29 |
30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31 | DEFAULT_JVM_OPTS=""
32 |
33 | # Use the maximum available, or set MAX_FD != -1 to use that value.
34 | MAX_FD="maximum"
35 |
36 | warn () {
37 | echo "$*"
38 | }
39 |
40 | die () {
41 | echo
42 | echo "$*"
43 | echo
44 | exit 1
45 | }
46 |
47 | # OS specific support (must be 'true' or 'false').
48 | cygwin=false
49 | msys=false
50 | darwin=false
51 | nonstop=false
52 | case "`uname`" in
53 | CYGWIN* )
54 | cygwin=true
55 | ;;
56 | Darwin* )
57 | darwin=true
58 | ;;
59 | MINGW* )
60 | msys=true
61 | ;;
62 | NONSTOP* )
63 | nonstop=true
64 | ;;
65 | esac
66 |
67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
68 |
69 | # Determine the Java command to use to start the JVM.
70 | if [ -n "$JAVA_HOME" ] ; then
71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72 | # IBM's JDK on AIX uses strange locations for the executables
73 | JAVACMD="$JAVA_HOME/jre/sh/java"
74 | else
75 | JAVACMD="$JAVA_HOME/bin/java"
76 | fi
77 | if [ ! -x "$JAVACMD" ] ; then
78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79 |
80 | Please set the JAVA_HOME variable in your environment to match the
81 | location of your Java installation."
82 | fi
83 | else
84 | JAVACMD="java"
85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86 |
87 | Please set the JAVA_HOME variable in your environment to match the
88 | location of your Java installation."
89 | fi
90 |
91 | # Increase the maximum file descriptors if we can.
92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93 | MAX_FD_LIMIT=`ulimit -H -n`
94 | if [ $? -eq 0 ] ; then
95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96 | MAX_FD="$MAX_FD_LIMIT"
97 | fi
98 | ulimit -n $MAX_FD
99 | if [ $? -ne 0 ] ; then
100 | warn "Could not set maximum file descriptor limit: $MAX_FD"
101 | fi
102 | else
103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104 | fi
105 | fi
106 |
107 | # For Darwin, add options to specify how the application appears in the dock
108 | if $darwin; then
109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110 | fi
111 |
112 | # For Cygwin, switch paths to Windows format before running java
113 | if $cygwin ; then
114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116 | JAVACMD=`cygpath --unix "$JAVACMD"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in $ROOTDIRSRAW ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args$i`="\"$arg\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case $i in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Escape application args
158 | save () {
159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160 | echo " "
161 | }
162 | APP_ARGS=$(save "$@")
163 |
164 | # Collect all arguments for the java command, following the shell quoting and substitution rules
165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
166 |
167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169 | cd "$(dirname "$0")"
170 | fi
171 |
172 | exec "$JAVACMD" "$@"
173 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/screenshots/screenshot_1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/starryalley/CSC_BLE_Bridge/bc153f2e7dd269246707dfc554bdab7c1fca4cbb/screenshots/screenshot_1.jpg
--------------------------------------------------------------------------------
/screenshots/screenshot_2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/starryalley/CSC_BLE_Bridge/bc153f2e7dd269246707dfc554bdab7c1fca4cbb/screenshots/screenshot_2.jpg
--------------------------------------------------------------------------------
/screenshots/screenshot_3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/starryalley/CSC_BLE_Bridge/bc153f2e7dd269246707dfc554bdab7c1fca4cbb/screenshots/screenshot_3.jpg
--------------------------------------------------------------------------------
/screenshots/screenshot_v1.3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/starryalley/CSC_BLE_Bridge/bc153f2e7dd269246707dfc554bdab7c1fca4cbb/screenshots/screenshot_v1.3.jpg
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | rootProject.name='CSC BLE Bridge'
2 | include ':app'
3 |
--------------------------------------------------------------------------------