├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── nl │ │ │ └── rwslinkman │ │ │ └── simdeviceble │ │ │ ├── AppModel.kt │ │ │ ├── MainActivity.kt │ │ │ ├── bluetooth │ │ │ ├── AdvertiseCommand.kt │ │ │ ├── AdvertisementManager.kt │ │ │ ├── BluetoothBytesParser.java │ │ │ ├── BluetoothDelegate.kt │ │ │ └── BluetoothUUID.kt │ │ │ ├── device │ │ │ ├── DigitalClock.kt │ │ │ ├── EarThermometer.kt │ │ │ ├── HeartRatePeripheral.kt │ │ │ └── model │ │ │ │ ├── Characteristic.kt │ │ │ │ ├── Device.kt │ │ │ │ └── Service.kt │ │ │ ├── grpc │ │ │ ├── EventListAdapter.kt │ │ │ ├── GrpcDataModel.kt │ │ │ ├── GrpcServerActivity.kt │ │ │ ├── HostData.kt │ │ │ └── server │ │ │ │ ├── GrpcActions.kt │ │ │ │ ├── GrpcEvents.kt │ │ │ │ ├── GrpcServer.kt │ │ │ │ └── SimDeviceGrpcService.kt │ │ │ ├── service │ │ │ ├── battery │ │ │ │ ├── BatteryLevelCharacteristic.kt │ │ │ │ └── BatteryService.kt │ │ │ ├── currenttime │ │ │ │ ├── CurrentTimeCharacteristic.kt │ │ │ │ └── CurrentTimeService.kt │ │ │ ├── deviceinformation │ │ │ │ ├── DeviceInformationService.kt │ │ │ │ ├── FirmwareRevisionCharacteristic.kt │ │ │ │ ├── HardwareRevisionCharacteristic.kt │ │ │ │ ├── ManufacturerNameCharacteristic.kt │ │ │ │ ├── ModelNumberCharacteristic.kt │ │ │ │ ├── PnpIdentifierCharacteristic.kt │ │ │ │ ├── RegulatoryCertificationCharacteristic.kt │ │ │ │ ├── SerialNumberCharacteristic.kt │ │ │ │ ├── SoftwareRevisionCharacteristic.kt │ │ │ │ └── SystemIdentifierCharacteristic.kt │ │ │ ├── healththermometer │ │ │ │ ├── HealthThermometerService.kt │ │ │ │ ├── MeasurementIntervalCharacteristic.kt │ │ │ │ └── TemperatureMeasurementCharacteristic.kt │ │ │ └── heartrate │ │ │ │ ├── BodySensorLocationCharacteristic.kt │ │ │ │ ├── HeartRateControlPointCharacteristic.kt │ │ │ │ ├── HeartRateMeasurementCharacteristic.kt │ │ │ │ └── HeartRateService.kt │ │ │ └── ui │ │ │ ├── data │ │ │ ├── CharacteristicDataViewHolder.kt │ │ │ ├── ServiceDataAdapter.kt │ │ │ ├── ServiceDataFragment.kt │ │ │ ├── ServiceDataViewHolder.kt │ │ │ └── controls │ │ │ │ ├── CharacteristicControls.kt │ │ │ │ ├── DecimalCharacteristicControls.kt │ │ │ │ ├── NumberCharacteristicControls.kt │ │ │ │ └── TextCharacteristicControls.kt │ │ │ ├── devices │ │ │ ├── SupportedDevicesAdapter.kt │ │ │ └── SupportedDevicesFragment.kt │ │ │ └── home │ │ │ └── HomeFragment.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── ic_bluetooth_black_24dp.xml │ │ ├── ic_devices_supported_black_24dp.xml │ │ ├── ic_home_black_24dp.xml │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ ├── activity_grpc_server.xml │ │ ├── activity_main.xml │ │ ├── card_advertising.xml │ │ ├── card_bluetooth_status.xml │ │ ├── card_connections.xml │ │ ├── characteristic_update_control_decimal.xml │ │ ├── characteristic_update_control_number.xml │ │ ├── characteristic_update_control_text.xml │ │ ├── fragment_home.xml │ │ ├── fragment_servicedata.xml │ │ ├── fragment_supported_devices.xml │ │ ├── list_item_characteristic.xml │ │ ├── list_item_grpc_event.xml │ │ ├── list_item_servicedata.xml │ │ ├── list_item_supported_characteristic.xml │ │ ├── list_item_supported_device.xml │ │ └── spinner_item_device.xml │ │ ├── menu │ │ ├── bottom_nav_menu.xml │ │ └── header_menu.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.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 │ │ ├── navigation │ │ └── mobile_navigation.xml │ │ └── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── nl │ └── rwslinkman │ └── simdeviceble │ └── ExampleUnitTest.kt ├── build.gradle ├── cucumbertest ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ ├── assets │ │ └── features │ │ │ ├── AdvertisementTest.feature │ │ │ └── GrpcConnectionTest.feature │ └── java │ │ └── nl │ │ └── rwslinkman │ │ └── simdeviceble │ │ └── cucumbertest │ │ └── test │ │ ├── ActivityScenarioHolder.kt │ │ ├── GrpcServerCucumberTests.kt │ │ ├── grpc │ │ ├── AdvertisedCharacteristic.kt │ │ ├── SimDevice.kt │ │ └── SimDeviceGrpcClient.kt │ │ └── steps │ │ ├── BluetoothSteps.kt │ │ ├── GeneralSteps.kt │ │ └── GrpcControlSteps.kt │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── nl │ │ └── rwslinkman │ │ └── simdeviceble │ │ └── cucumbertest │ │ ├── AndroidExt.kt │ │ ├── AppPermissionChecker.kt │ │ └── CucumberTestActivity.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ ├── ic_bluetooth_black_24dp.xml │ ├── ic_gear_black_24dp.xml │ ├── ic_launcher_background.xml │ ├── ic_location_black_24dp.xml │ ├── ic_success_black_24dp.xml │ └── ic_warning_black_24dp.xml │ ├── layout │ └── activity_cucumber_test.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-mdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ └── values │ ├── colors.xml │ ├── strings.xml │ └── themes.xml ├── docs ├── image_data_fragment.jpg └── image_home_fragment.jpg ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── grpc ├── SimDeviceBLE.proto ├── compiler │ ├── protoc-gen-grpc-java-1460 │ └── protoc-gen-grpc-java-1530 └── generate_java.sh └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.aar 4 | *.ap_ 5 | *.aab 6 | 7 | # Files for the ART/Dalvik VM 8 | *.dex 9 | 10 | # Java class files 11 | *.class 12 | 13 | # Generated files 14 | bin/ 15 | gen/ 16 | out/ 17 | # Uncomment the following line in case you need and you don't have the release build type files in your app 18 | # release/ 19 | 20 | # Gradle files 21 | .gradle/ 22 | build/ 23 | 24 | # Local configuration file (sdk path, etc) 25 | local.properties 26 | 27 | # Proguard folder generated by Eclipse 28 | proguard/ 29 | 30 | # Log Files 31 | *.log 32 | 33 | # Android Studio Navigation editor temp files 34 | .navigation/ 35 | 36 | # Android Studio captures folder 37 | captures/ 38 | 39 | # IntelliJ 40 | *.iml 41 | .idea/workspace.xml 42 | .idea/tasks.xml 43 | .idea/gradle.xml 44 | .idea/assetWizardSettings.xml 45 | .idea/dictionaries 46 | .idea/libraries 47 | # Android Studio 3 in .gitignore file. 48 | .idea/caches 49 | .idea/modules.xml 50 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you 51 | .idea/navEditor.xml 52 | 53 | # Keystore files 54 | # Uncomment the following lines if you do not want to check your keystore files in. 55 | #*.jks 56 | #*.keystore 57 | 58 | # External native build folder generated in Android Studio 2.2 and later 59 | .externalNativeBuild 60 | .cxx/ 61 | 62 | # Google Services (e.g. APIs or Firebase) 63 | # google-services.json 64 | 65 | # Freeline 66 | freeline.py 67 | freeline/ 68 | freeline_project_description.json 69 | 70 | # fastlane 71 | fastlane/report.xml 72 | fastlane/Preview.html 73 | fastlane/screenshots 74 | fastlane/test_output 75 | fastlane/readme.md 76 | 77 | # Version control 78 | vcs.xml 79 | 80 | # lint 81 | lint/intermediates/ 82 | lint/generated/ 83 | lint/outputs/ 84 | lint/tmp/ 85 | # lint/reports/ 86 | .gradle 87 | /local.properties 88 | /.idea/caches 89 | /.idea/libraries 90 | /.idea/modules.xml 91 | /.idea/workspace.xml 92 | /.idea/navEditor.xml 93 | /.idea/assetWizardSettings.xml 94 | .DS_Store 95 | /build 96 | /captures 97 | .cxx 98 | .idea 99 | grpc/generated_java 100 | grpc/generated_kt 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SimDeviceBLE 2 | Simulate a BLE device using this Android app 3 | 4 | SimDeviceBLE allows developers to simulate Bluetooth devices with multiple GATT Services. 5 | This **app** allows full configuration of the BLE advertisement data and shows the amount of connected devices. 6 | The simulated device and the advertised data can be manipulated using the app UI or the **gRPC** interface. 7 | 8 | Feel free to import the `app` module into your Android Studio project to simulate your own BLE peripherals. 9 | Contributing the proprietary devices, services and characteristics is appreciated but not required. 10 | 11 | ## App 12 | Checkout the project and open it with Android Studio. 13 | Run the `app` on your Android phone. 14 | 15 | Make sure Bluetooth is enabled on your phone (use `Enable Bluetooth` button) and advertise a selected device. 16 | The selected device will advertise the services associated to it. 17 | All options available in the `Advertise` section will be used in the Advertisement data. 18 | 19 | Characteristic values can be updated using the `Service Data` screen. 20 | All characteristics will be mapped to a View on the screen to show their current data value. 21 | Some characteristics will allow to update their value and notify all connected Central devices. 22 | Developers do not need to write their own View classes. 23 | Services and characteristics are introspected and Views are created dynamically. 24 | 25 | ![Home screen allows for configuration of Advertisement data](docs/image_home_fragment.jpg) 26 | ![Service Data screen manipulates data of all advertised characteristics](docs/image_data_fragment.jpg) 27 | 28 | The Gradle configuration uses the `protoc-gen-grpc-java` plugin for `protoc` to generate classes. 29 | Download the compiler from [here](https://repo1.maven.org/maven2/io/grpc/protoc-gen-grpc-java/) for your situation. 30 | 31 | ## gRPC 32 | SimDeviceBLE defines a [gRPC](https://grpc.io/) interface to take control of the BLE device. 33 | Generate a client [in your preferred language](https://grpc.io/docs/languages/) to interact with the gRPC server in the app. 34 | All devices supported in the app can be advertised with a simple command. 35 | Update the advertised characteristic data to match your use case or test scenario. 36 | To notify all connected devices of an updated characteristic in SimDeviceBLE, call the associated `rpc`. 37 | 38 | SimDeviceBLE has a `GrpcServerActivity` that can be used in an easy way. 39 | It is available via the options menu in the app. 40 | 41 | For usage in automation, please follow these steps: 42 | - Install the SimDeviceBLE app on an Android phone and connect to a PC using USB. 43 | - Run command `adb forward tcp:8910 tcp:8910` 44 | - Run command `adb shell am start -n nl.rwslinkman.simdeviceble/.grpc.GrpcServerActivity` 45 | 46 | ```protobuf 47 | service SimDeviceBLE { 48 | 49 | rpc listAvailableSimDevices(google.protobuf.Empty) returns (ListAvailableSimDevicesResponse) {} 50 | rpc startAdvertisement(StartAdvertisementRequest) returns (StartAdvertisementResponse) {} 51 | rpc stopAdvertisement(google.protobuf.Empty) returns (google.protobuf.Empty) {} 52 | rpc listAdvertisedCharacteristics(google.protobuf.Empty) returns (ListAdvertisedCharacteristicsResponse) {} 53 | rpc updateCharacteristicValue(UpdateCharacteristicValueRequest) returns (google.protobuf.Empty) {} 54 | rpc notifyCharacteristic(NotifyCharacteristicRequest) returns (google.protobuf.Empty) {} 55 | } 56 | ``` 57 | Please refer to the `grpc/SimDeviceBLE.proto` file for the full specification of the gRPC interface. 58 | 59 | ## Automated testing 60 | The module `cucumbertest` contains a basic app that is used as a testing vehicle to verify SimDeviceBLE. 61 | Tests written using the [Cucumber framework](https://github.com/cucumber/cucumber-android) are executed via this app. 62 | 63 | Execute the `androidTest` tests in the `cucumberTest` module to test the gRPC server. 64 | It requires a separate device running SimDeviceBLE in gRPC Mode. 65 | This mode can be found in the context menu of the SimDeviceBLE app. 66 | 67 | The Cucumber tests demonstrate a way SimDeviceBLE can contribute to your automated testing. 68 | Your project could create a similar configuration using the gRPC client. 69 | 70 | ## Contributing 71 | Please feel free to add any devices in the `nl.rwslinkman.simdeviceble.device` package. 72 | All devices must implement the `Device` interface. 73 | The list of `services` can contain any of the classes implementing `Service`. 74 | They are defined in the `nl.rwslinkman.simdeviceble.service` package. 75 | Don't forget to add the new device in the `AppModel.supportedDevices` list. 76 | 77 | When adding a new `Service`, please implement according to the Bluetooth SIG specification as much as possible. 78 | Please keep in mind that the services might be used by multiple `Device` implementations. 79 | 80 | ### Notice 81 | BLE peripheral mode was introduced in Android 5.0 Lollipop. 82 | Due to hardware chipset dependency, some Android phones don't have access to this feature. 83 | This will be visible in the `Bluetooth` section on the Home screen. -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'org.jetbrains.kotlin.android' 3 | 4 | android { 5 | compileSdkVersion 33 6 | 7 | defaultConfig { 8 | applicationId "nl.rwslinkman.simdeviceble" 9 | minSdkVersion 21 10 | targetSdkVersion 33 11 | versionCode 1 12 | versionName "1.0" 13 | multiDexEnabled true 14 | 15 | testInstrumentationRunner "io.cucumber.android.runner.CucumberAndroidJUnitRunner" 16 | testApplicationId "nl.rwslinkman.simdeviceble.test" 17 | } 18 | sourceSets { 19 | main { 20 | java { 21 | srcDirs += "build/generated/src" 22 | } 23 | } 24 | } 25 | buildTypes { 26 | release { 27 | minifyEnabled false 28 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 29 | } 30 | } 31 | compileOptions { 32 | sourceCompatibility JavaVersion.VERSION_1_8 33 | targetCompatibility JavaVersion.VERSION_1_8 34 | } 35 | kotlinOptions { 36 | jvmTarget = '1.8' 37 | } 38 | packagingOptions { 39 | resources { 40 | pickFirsts += ['META-INF/INDEX.LIST', 'META-INF/LICENSE', 'META-INF/io.netty.versions.properties', 'META-INF/services/io.grpc.ManagedChannelProvider'] 41 | } 42 | } 43 | buildFeatures { 44 | viewBinding true 45 | } 46 | namespace 'nl.rwslinkman.simdeviceble' 47 | } 48 | 49 | dependencies { 50 | implementation fileTree(dir: "libs", include: ["*.jar"]) 51 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 52 | implementation "androidx.core:core-ktx:1.9.0" 53 | implementation "androidx.appcompat:appcompat:1.6.1" 54 | implementation "androidx.constraintlayout:constraintlayout:2.1.4" 55 | implementation "com.google.android.material:material:1.6.0" 56 | implementation "androidx.navigation:navigation-fragment-ktx:2.5.3" 57 | implementation "androidx.navigation:navigation-ui-ktx:2.5.3" 58 | implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" 59 | implementation "androidx.navigation:navigation-fragment-ktx:2.5.3" 60 | implementation "androidx.navigation:navigation-ui-ktx:2.5.3" 61 | implementation "androidx.legacy:legacy-support-v4:1.0.0" 62 | implementation "io.grpc:grpc-netty:1.46.0" 63 | implementation "io.grpc:grpc-protobuf:1.53.0" 64 | implementation "io.grpc:grpc-stub:1.53.0" 65 | implementation "com.google.protobuf:protobuf-java:3.21.12" 66 | implementation "javax.annotation:javax.annotation-api:1.3.2" 67 | 68 | testImplementation "junit:junit:4.13.2" 69 | androidTestImplementation "androidx.test.ext:junit:1.1.5" 70 | androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1" 71 | } 72 | 73 | task compileGrpcClasses { 74 | // TODO: This could be an actual Gradle plugin that takes into account the user's OS 75 | doLast { 76 | def proc = "grpc/generate_java.sh".execute() 77 | proc.waitForProcessOutput(System.out, System.err) 78 | } 79 | } 80 | preBuild.dependsOn compileGrpcClasses -------------------------------------------------------------------------------- /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 | 23 | -keepnames io.grpc.ServerProvider 24 | -keep io.grpc.netty.NettyServerProvider -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 19 | 20 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/AppModel.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import androidx.lifecycle.ViewModel 5 | import nl.rwslinkman.simdeviceble.bluetooth.AdvertisementManager 6 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothDelegate 7 | import nl.rwslinkman.simdeviceble.device.DigitalClock 8 | import nl.rwslinkman.simdeviceble.device.EarThermometer 9 | import nl.rwslinkman.simdeviceble.device.HeartRatePeripheral 10 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 11 | import nl.rwslinkman.simdeviceble.device.model.Device 12 | import java.util.* 13 | 14 | class AppModel: ViewModel(), AdvertisementManager.Listener { 15 | 16 | // Data for Bluetooth UI 17 | val bluetoothSupported: MutableLiveData = MutableLiveData(false) 18 | val bluetoothEnabled: MutableLiveData = MutableLiveData(false) 19 | val bluetoothAdvertisingSupported: MutableLiveData = MutableLiveData(false) 20 | // Data for Advertising UI 21 | val isAdvertising: MutableLiveData = MutableLiveData(false) 22 | val advertisementName: MutableLiveData = MutableLiveData("Unknown") 23 | val activeDevice: MutableLiveData = MutableLiveData(supportedDevices.first()) 24 | val isConnectable: MutableLiveData = MutableLiveData(true) 25 | val advertiseDeviceName: MutableLiveData = MutableLiveData(true) 26 | // Data for Connections UI 27 | private val _connDevices: MutableSet = mutableSetOf() 28 | val connectedDevices: MutableLiveData> = MutableLiveData() 29 | // Characteristic data manipulation 30 | private val _dataContainer: MutableMap = mutableMapOf() 31 | val presentableDataContainer: MutableLiveData> = MutableLiveData(mutableMapOf()) 32 | 33 | // Handle to Activity for BLE related operations 34 | val bluetoothDelegate: MutableLiveData = MutableLiveData() 35 | 36 | fun enableBluetooth() { 37 | bluetoothDelegate.value?.turnOnBluetooth() 38 | } 39 | 40 | fun selectDevice(device: Device) { 41 | activeDevice.postValue(device) 42 | } 43 | 44 | fun startAdvertising() { 45 | activeDevice.value?.let { 46 | val allowDeviceName: Boolean = if (advertiseDeviceName.value == null) defaultAllowDeviceName else advertiseDeviceName.value!! 47 | val isConnectable: Boolean = if (isConnectable.value == null) defaultIsConnectable else isConnectable.value!! 48 | bluetoothDelegate.value?.advertise(it, allowDeviceName, isConnectable) 49 | } 50 | } 51 | 52 | fun stopAdvertising() { 53 | bluetoothDelegate.value?.stopAdvertising() 54 | _dataContainer.clear() 55 | } 56 | 57 | override fun onDeviceConnected(deviceAddress: String) { 58 | _connDevices.add(deviceAddress) 59 | connectedDevices.postValue(_connDevices.toList()) 60 | } 61 | 62 | override fun onDeviceDisconnected(deviceAddress: String) { 63 | _connDevices.remove(deviceAddress) 64 | connectedDevices.postValue(_connDevices.toList()) 65 | } 66 | 67 | override fun updateDataContainer(characteristic: Characteristic, data: ByteArray, isInitialValue: Boolean) { 68 | _dataContainer[characteristic.uuid] = data 69 | postDataContainer() 70 | } 71 | 72 | override fun setIsAdvertising(isAdvertising: Boolean, advertisedDevice: String?) { 73 | this.isAdvertising.postValue(isAdvertising) 74 | } 75 | 76 | fun updateCharacteristicValue(characteristic: Characteristic, value: String) { 77 | val byteValue: ByteArray = characteristic.convertToBytes(value) 78 | updateDataContainer(characteristic, byteValue, false) 79 | } 80 | 81 | fun sendCharacteristicNotification(characteristic: Characteristic) { 82 | bluetoothDelegate.value?.sendNotificationToConnectedDevices(characteristic) 83 | } 84 | 85 | private fun postDataContainer() { 86 | bluetoothDelegate.value?.updateCharacteristicValues(_dataContainer) 87 | 88 | // Convert to data for Fragments 89 | val presentable = mutableMapOf() 90 | _dataContainer.forEach { 91 | val char = activeDevice.value!!.getCharacteristic(it.key) 92 | presentable[it.key] = char?.convertToPresentable(it.value) ?: "" 93 | } 94 | presentableDataContainer.postValue(presentable) 95 | } 96 | 97 | companion object { 98 | const val sourcesLink: String = "https://github.com/rwslinkman/simdeviceble" 99 | const val developerLink: String = "https://rwslinkman.nl" 100 | val supportedDevices: List = listOf( 101 | HeartRatePeripheral(), 102 | DigitalClock(), 103 | EarThermometer() 104 | ) 105 | const val defaultAllowDeviceName: Boolean = true 106 | const val defaultIsConnectable: Boolean = true 107 | } 108 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble 2 | 3 | import android.bluetooth.BluetoothAdapter 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import android.view.Menu 7 | import android.view.MenuItem 8 | import androidx.activity.viewModels 9 | import androidx.appcompat.app.AppCompatActivity 10 | import androidx.navigation.findNavController 11 | import androidx.navigation.ui.AppBarConfiguration 12 | import androidx.navigation.ui.setupActionBarWithNavController 13 | import androidx.navigation.ui.setupWithNavController 14 | import com.google.android.material.bottomnavigation.BottomNavigationView 15 | import nl.rwslinkman.simdeviceble.bluetooth.AdvertiseCommand 16 | import nl.rwslinkman.simdeviceble.bluetooth.AdvertisementManager 17 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothDelegate 18 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 19 | import nl.rwslinkman.simdeviceble.device.model.Device 20 | import nl.rwslinkman.simdeviceble.grpc.GrpcServerActivity 21 | import java.util.* 22 | 23 | 24 | class MainActivity : AppCompatActivity() { 25 | 26 | private val appModel: AppModel by viewModels() 27 | private var bluetoothAdapter: BluetoothAdapter? = null 28 | private var advManager: AdvertisementManager? = null 29 | 30 | private val btDelegate = object : 31 | BluetoothDelegate { 32 | override fun turnOnBluetooth() { 33 | startBluetoothIntent() 34 | } 35 | 36 | override fun advertise(device: Device, includeDeviceName: Boolean, isConnectable: Boolean) { 37 | val command = AdvertiseCommand(device, includeDeviceName, isConnectable) 38 | advManager?.advertise(command) 39 | } 40 | 41 | override fun stopAdvertising() { 42 | advManager?.stop() 43 | } 44 | 45 | override fun updateCharacteristicValues(characteristicData: MutableMap) { 46 | advManager?.updateAdvertisedCharacteristics(characteristicData) 47 | } 48 | 49 | override fun sendNotificationToConnectedDevices(characteristic: Characteristic) { 50 | advManager?.sendNotificationToConnectedDevices(characteristic) 51 | } 52 | } 53 | 54 | override fun onCreate(savedInstanceState: Bundle?) { 55 | super.onCreate(savedInstanceState) 56 | setContentView(R.layout.activity_main) 57 | 58 | setupBluetooth() 59 | setupNavigation() 60 | appModel.bluetoothDelegate.postValue(btDelegate) 61 | } 62 | 63 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 64 | super.onActivityResult(requestCode, resultCode, data) 65 | if (requestCode == REQUEST_ENABLE_BT) { 66 | // Bluetooth on/off 67 | val isEnabled = resultCode == RESULT_OK 68 | appModel.bluetoothEnabled.postValue(isEnabled) 69 | 70 | // Advertising supported 71 | val canAdvertise = bluetoothAdapter?.isMultipleAdvertisementSupported 72 | appModel.bluetoothAdvertisingSupported.postValue(canAdvertise) 73 | } 74 | } 75 | 76 | override fun onPause() { 77 | advManager?.stop() 78 | super.onPause() 79 | } 80 | 81 | override fun onCreateOptionsMenu(menu: Menu?): Boolean { 82 | menuInflater.inflate(R.menu.header_menu, menu) 83 | return true 84 | } 85 | 86 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 87 | if(item.itemId == R.id.menu_item_grpc) { 88 | val intent = Intent(this, GrpcServerActivity::class.java) 89 | startActivity(intent) 90 | return true 91 | } 92 | return super.onOptionsItemSelected(item) 93 | } 94 | 95 | private fun startBluetoothIntent() { 96 | val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) 97 | startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) 98 | } 99 | 100 | private fun setupNavigation() { 101 | val navView: BottomNavigationView = findViewById(R.id.nav_view) 102 | val navController = findNavController(R.id.nav_host_fragment) 103 | val appBarConfiguration = AppBarConfiguration( 104 | setOf( 105 | R.id.navigation_home, 106 | R.id.navigation_supported_devices, 107 | R.id.navigation_servicedata 108 | ) 109 | ) 110 | setupActionBarWithNavController(navController, appBarConfiguration) 111 | navView.setupWithNavController(navController) 112 | } 113 | 114 | private fun setupBluetooth() { 115 | bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() 116 | appModel.bluetoothSupported.postValue(bluetoothAdapter != null) 117 | 118 | bluetoothAdapter?.let { 119 | advManager = AdvertisementManager(this, it, appModel) 120 | 121 | appModel.bluetoothEnabled.postValue(it.isEnabled) 122 | appModel.bluetoothAdvertisingSupported.postValue(it.isMultipleAdvertisementSupported) 123 | // appModel.advertisementName.postValue(it.name) 124 | } 125 | } 126 | 127 | companion object { 128 | const val REQUEST_ENABLE_BT = 1337 129 | } 130 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/bluetooth/AdvertiseCommand.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.bluetooth 2 | 3 | import nl.rwslinkman.simdeviceble.device.model.Device 4 | 5 | data class AdvertiseCommand(val device: Device, val includeDeviceName: Boolean, val isConnectable: Boolean) { 6 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/bluetooth/BluetoothDelegate.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.bluetooth 2 | 3 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 4 | import nl.rwslinkman.simdeviceble.device.model.Device 5 | import java.util.* 6 | 7 | interface BluetoothDelegate { 8 | 9 | fun turnOnBluetooth() 10 | 11 | fun advertise(device: Device, includeDeviceName: Boolean, isConnectable: Boolean) 12 | 13 | fun stopAdvertising() 14 | 15 | fun updateCharacteristicValues(characteristicData: MutableMap) 16 | 17 | fun sendNotificationToConnectedDevices(characteristic: Characteristic) 18 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/bluetooth/BluetoothUUID.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.bluetooth 2 | 3 | import java.util.* 4 | 5 | class BluetoothUUID { 6 | companion object { 7 | fun fromSigNumber(sigNumber: String): UUID { 8 | return UUID.fromString("0000${sigNumber.lowercase()}-0000-1000-8000-00805f9b34fb") 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/device/DigitalClock.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.device 2 | 3 | import nl.rwslinkman.simdeviceble.device.model.Device 4 | import nl.rwslinkman.simdeviceble.device.model.Service 5 | import nl.rwslinkman.simdeviceble.service.battery.BatteryService 6 | import nl.rwslinkman.simdeviceble.service.currenttime.CurrentTimeService 7 | import nl.rwslinkman.simdeviceble.service.deviceinformation.DeviceInformationService 8 | import java.util.* 9 | 10 | class DigitalClock: Device() { 11 | override val name: String 12 | get() = "Digital Clock" 13 | 14 | override val primaryServiceUuid: UUID 15 | get() = CurrentTimeService.SERVICE_UUID 16 | 17 | override val services: List 18 | get() = listOf( 19 | BatteryService(), 20 | CurrentTimeService(), 21 | DeviceInformationService() 22 | ) 23 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/device/EarThermometer.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.device 2 | 3 | import nl.rwslinkman.simdeviceble.device.model.Device 4 | import nl.rwslinkman.simdeviceble.device.model.Service 5 | import nl.rwslinkman.simdeviceble.service.battery.BatteryService 6 | import nl.rwslinkman.simdeviceble.service.healththermometer.HealthThermometerService 7 | import java.util.* 8 | 9 | class EarThermometer: Device() { 10 | 11 | override val name: String 12 | get() = "Thermometer (Ear)" 13 | 14 | override val primaryServiceUuid: UUID 15 | get() = HealthThermometerService.SERVICE_UUID 16 | 17 | override val services: List 18 | get() = listOf( 19 | HealthThermometerService(), 20 | BatteryService() 21 | ) 22 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/device/HeartRatePeripheral.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.device 2 | 3 | import nl.rwslinkman.simdeviceble.device.model.Device 4 | import nl.rwslinkman.simdeviceble.device.model.Service 5 | import nl.rwslinkman.simdeviceble.service.battery.BatteryService 6 | import nl.rwslinkman.simdeviceble.service.heartrate.HeartRateService 7 | import java.util.* 8 | 9 | class HeartRatePeripheral: Device() { 10 | 11 | override val name: String 12 | get() = "Fitness Band (Chest)" 13 | 14 | override val primaryServiceUuid: UUID 15 | get() = HeartRateService.SERVICE_UUID 16 | 17 | override val services: List 18 | get() = listOf( 19 | HeartRateService(), 20 | BatteryService() 21 | ) 22 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/device/model/Characteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.device.model 2 | 3 | import java.util.* 4 | 5 | interface Characteristic { 6 | 7 | enum class Type { 8 | Number, 9 | Text, 10 | Decimal 11 | } 12 | 13 | val name: String 14 | val uuid: UUID 15 | val type: Type 16 | 17 | val isRead: Boolean 18 | get() = false 19 | 20 | val isWrite: Boolean 21 | get() = false 22 | 23 | val isNotify: Boolean 24 | get() = false 25 | 26 | val isIndicate: Boolean 27 | get() = false 28 | 29 | val description: String? 30 | get() = null 31 | 32 | val initialValue: ByteArray? 33 | get() = null 34 | 35 | fun validateWrite(offset: Int, value: ByteArray?): Int 36 | fun convertToPresentable(value: ByteArray): String 37 | fun convertToBytes(value: String): ByteArray 38 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/device/model/Device.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.device.model 2 | 3 | import android.util.Log 4 | import java.util.* 5 | 6 | abstract class Device { 7 | 8 | abstract val name: String 9 | abstract val primaryServiceUuid: UUID 10 | abstract val services: List 11 | 12 | fun getCharacteristic(uuid: UUID?): Characteristic? { 13 | val result = services.flatMap { 14 | it.characteristics 15 | }.first { uuid == it.uuid } 16 | Log.d(TAG, "getCharacteristic: uuid found $result") 17 | return result 18 | } 19 | 20 | companion object { 21 | const val TAG = "Device" 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/device/model/Service.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.device.model 2 | 3 | import java.util.* 4 | 5 | interface Service { 6 | val name: String 7 | val uuid: UUID 8 | val characteristics: List 9 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/grpc/EventListAdapter.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.grpc 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import android.widget.TextView 7 | import androidx.recyclerview.widget.RecyclerView 8 | import nl.rwslinkman.simdeviceble.R 9 | import java.text.SimpleDateFormat 10 | import java.util.* 11 | 12 | class EventListAdapter: RecyclerView.Adapter() { 13 | 14 | private val dataSet: MutableList = mutableListOf() 15 | 16 | data class Item(val data: String, val timestamp: Date = Date()) 17 | 18 | class EventViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 19 | val dateView: TextView = itemView.findViewById(R.id.item_grpc_event_timestamp) 20 | val eventView: TextView = itemView.findViewById(R.id.item_grpc_event_data) 21 | } 22 | 23 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EventViewHolder { 24 | val itemRoot: View = LayoutInflater.from(parent.context) 25 | .inflate(R.layout.list_item_grpc_event, parent, false) 26 | return EventViewHolder(itemRoot) 27 | } 28 | 29 | override fun getItemCount(): Int { 30 | return dataSet.size 31 | } 32 | 33 | override fun onBindViewHolder(holder: EventViewHolder, position: Int) { 34 | val event: Item = dataSet[position] 35 | val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.sss", Locale.getDefault()) 36 | val eventDate: String = sdf.format(event.timestamp) 37 | 38 | holder.dateView.text = eventDate 39 | holder.eventView.text = event.data 40 | } 41 | 42 | fun addGrpcEvent(eventItem: Item) { 43 | dataSet.add(eventItem) 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/grpc/GrpcDataModel.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.grpc 2 | 3 | import com.google.protobuf.ByteString 4 | import nl.rwslinkman.simdeviceble.AppModel 5 | import nl.rwslinkman.simdeviceble.bluetooth.AdvertiseCommand 6 | import nl.rwslinkman.simdeviceble.bluetooth.AdvertisementManager 7 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 8 | import nl.rwslinkman.simdeviceble.device.model.Device 9 | import nl.rwslinkman.simdeviceble.grpc.server.* 10 | import java.util.* 11 | 12 | class GrpcDataModel(private val advertisementManager: AdvertisementManager, private val hostData: HostData) : 13 | GrpcActionHandler { 14 | 15 | override fun getSupportedDevices(): List { 16 | return allDevices().map(::convertDevice) 17 | } 18 | 19 | override fun startAdvertisement(command: AdvertisementStartCommand): AdvertisementData { 20 | val device: Device = allDevices().find { it.name == command.device } 21 | ?: throw IllegalArgumentException("Device '${command.device}' is not supported") 22 | 23 | val includesDeviceName = command.advertiseDeviceName ?: true 24 | val isConnectable = command.connectable ?: true 25 | val startCommand = AdvertiseCommand( 26 | device, 27 | includesDeviceName, 28 | isConnectable 29 | ) 30 | advertisementManager.advertise(startCommand) 31 | return AdvertisementData( 32 | hostData.advertisementName, 33 | device.primaryServiceUuid, 34 | isConnectable, 35 | includesDeviceName 36 | ) 37 | } 38 | 39 | override fun stopAdvertisement() { 40 | advertisementManager.stop() 41 | } 42 | 43 | override fun listAdvertisedCharacteristics(): List { 44 | val data = advertisementManager.getAdvertisedCharacteristicValues() 45 | return data.map { 46 | val byteString = ByteString.copyFrom(it.value) 47 | val builder = nl.rwslinkman.simdeviceble.grpc.server.Characteristic 48 | .newBuilder() 49 | .setUuid(it.key.uuid.toString()) 50 | .setName(it.key.name) 51 | .setCurrentValue(byteString) 52 | builder.build() 53 | } 54 | } 55 | 56 | override fun updateCharacteristicValue(uuid: String?, data: ByteArray?) { 57 | val charUUID: UUID = UUID.fromString(uuid ?: throw IllegalArgumentException("Property 'uuid' is required")) 58 | val updateData: ByteArray = data ?: throw IllegalArgumentException("Property 'data' is required") 59 | 60 | val charMap = mutableMapOf(Pair(charUUID, updateData)) 61 | advertisementManager.updateAdvertisedCharacteristics(charMap) 62 | } 63 | 64 | override fun notifyCharacteristic(uuid: String?) { 65 | val charUUID = UUID.fromString(uuid ?: throw IllegalArgumentException("Property 'uuid' is required")) 66 | advertisementManager.sendNotificationToConnectedDevices(charUUID) 67 | } 68 | 69 | private fun convertDevice(device: Device): SimDevice { 70 | val simDeviceBuilder = SimDevice.newBuilder() 71 | .setName(device.name) 72 | .setPrimaryServiceUUID(device.primaryServiceUuid.toString()) 73 | 74 | device.services.map(::convertService).apply { 75 | simDeviceBuilder.addAllServices(this) 76 | } 77 | return simDeviceBuilder.build() 78 | } 79 | 80 | private fun convertService(service: nl.rwslinkman.simdeviceble.device.model.Service): Service { 81 | val simServiceBuilder = Service.newBuilder() 82 | .setName(service.name) 83 | .setUuid(service.uuid.toString()) 84 | 85 | service.characteristics.map(::convertChar).apply { 86 | simServiceBuilder.addAllCharacteristics(this) 87 | } 88 | 89 | return simServiceBuilder.build() 90 | } 91 | 92 | private fun convertChar(characteristic: Characteristic): nl.rwslinkman.simdeviceble.grpc.server.Characteristic { 93 | val simCharBuilder = nl.rwslinkman.simdeviceble.grpc.server.Characteristic.newBuilder() 94 | .setName(characteristic.name) 95 | .setUuid(characteristic.uuid.toString()) 96 | return simCharBuilder.build() 97 | } 98 | 99 | private fun allDevices(): List = AppModel.supportedDevices 100 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/grpc/GrpcServerActivity.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.grpc 2 | 3 | import android.bluetooth.BluetoothAdapter 4 | import android.os.Bundle 5 | import android.widget.TextView 6 | import androidx.appcompat.app.AppCompatActivity 7 | import androidx.recyclerview.widget.LinearLayoutManager 8 | import androidx.recyclerview.widget.RecyclerView 9 | import nl.rwslinkman.simdeviceble.R 10 | import nl.rwslinkman.simdeviceble.bluetooth.AdvertisementManager 11 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 12 | import nl.rwslinkman.simdeviceble.grpc.server.GrpcCall 13 | import nl.rwslinkman.simdeviceble.grpc.server.GrpcEventListener 14 | import nl.rwslinkman.simdeviceble.grpc.server.GrpcServer 15 | 16 | class GrpcServerActivity : AppCompatActivity() { 17 | // grpc 18 | private val grpcPort = 8911 19 | private val grpcServer = GrpcServer(grpcPort) 20 | private lateinit var dataModel: GrpcDataModel 21 | 22 | // ble 23 | private var isBluetoothSupported = false 24 | private var bluetoothAdapter: BluetoothAdapter? = null 25 | private lateinit var advManager: AdvertisementManager 26 | private lateinit var hostData: HostData 27 | 28 | // ui 29 | private lateinit var statusSubtitle: TextView 30 | private lateinit var grpcEventListView: RecyclerView 31 | private val grpcEventAdapter = EventListAdapter() 32 | 33 | private val eventListener = object : GrpcEventListener { 34 | override fun onGrpcServerStarted() { 35 | statusSubtitle.text = getString(R.string.grpc_server_running, grpcPort) 36 | addEventToView("gRPC server has started") 37 | } 38 | 39 | override fun onGrpcServerStopped() { 40 | statusSubtitle.text = getText(R.string.grpc_server_idle) 41 | addEventToView("gRPC server was stopped") 42 | } 43 | 44 | override fun onGrpcCallReceived(event: GrpcCall) { 45 | val eventDetails = "Received gRPC event ${event.name}" 46 | addEventToView(eventDetails) 47 | } 48 | } 49 | 50 | private val btDelegate = object : AdvertisementManager.Listener { 51 | override fun updateDataContainer(characteristic: Characteristic, data: ByteArray, isInitialValue: Boolean) { 52 | if(isInitialValue) return 53 | addEventToView("Value of ${characteristic.name} was updated") 54 | } 55 | 56 | override fun setIsAdvertising(isAdvertising: Boolean, advertisedDevice: String?) { 57 | val event = if(isAdvertising) { 58 | "SimDeviceBLE has started advertising as a '${advertisedDevice ?: "unknown"}' device" 59 | } 60 | else "SimDeviceBLE has stopped advertising" 61 | addEventToView(event) 62 | } 63 | 64 | override fun onDeviceConnected(deviceAddress: String) { 65 | addEventToView("Device '$deviceAddress' has connected to SimDeviceBLE") 66 | } 67 | 68 | override fun onDeviceDisconnected(deviceAddress: String) { 69 | addEventToView("Device '$deviceAddress' has disconnected from SimDeviceBLE") 70 | } 71 | } 72 | 73 | override fun onCreate(savedInstanceState: Bundle?) { 74 | super.onCreate(savedInstanceState) 75 | setContentView(R.layout.activity_grpc_server) 76 | 77 | statusSubtitle = findViewById(R.id.simdeviceble_grpc_subtitle) 78 | 79 | grpcEventListView = findViewById(R.id.simdeviceble_grpc_eventlist) 80 | grpcEventListView.apply { 81 | setHasFixedSize(true) 82 | layoutManager = LinearLayoutManager(context) 83 | adapter = grpcEventAdapter 84 | } 85 | 86 | setupBluetooth() 87 | } 88 | 89 | override fun onResume() { 90 | super.onResume() 91 | grpcServer.actionHandler = dataModel 92 | grpcServer.eventListener = eventListener 93 | grpcServer.start() 94 | } 95 | 96 | override fun onDestroy() { 97 | super.onDestroy() 98 | grpcServer.stop() 99 | advManager.stop() 100 | } 101 | 102 | private fun setupBluetooth() { 103 | bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() 104 | isBluetoothSupported = bluetoothAdapter != null 105 | 106 | bluetoothAdapter?.let { 107 | advManager = AdvertisementManager(this, it, btDelegate) 108 | 109 | hostData = HostData(it.name, it.isMultipleAdvertisementSupported) 110 | dataModel = GrpcDataModel(advManager, hostData) 111 | } 112 | } 113 | 114 | private fun addEventToView(eventDetails: String) { 115 | runOnUiThread { 116 | grpcEventAdapter.addGrpcEvent(EventListAdapter.Item(eventDetails)) 117 | val insertedPos = grpcEventAdapter.itemCount - 1 118 | grpcEventAdapter.notifyItemInserted(insertedPos) 119 | grpcEventListView.scrollToPosition(insertedPos) 120 | } 121 | } 122 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/grpc/HostData.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.grpc 2 | 3 | data class HostData(val advertisementName: String, val advertisingSupported: Boolean) -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/grpc/server/GrpcActions.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.grpc.server 2 | 3 | import java.util.* 4 | 5 | data class AdvertisementData(val advertisementName: String, val primaryServiceUUID: UUID, val isConnectable: Boolean, val isAdvertisingDeviceName: Boolean) 6 | data class AdvertisementStartCommand(val device: String?, val connectable: Boolean?, val advertiseDeviceName: Boolean?) 7 | 8 | interface GrpcActionHandler { 9 | 10 | fun getSupportedDevices(): List 11 | fun startAdvertisement(command: AdvertisementStartCommand): AdvertisementData 12 | fun stopAdvertisement() 13 | fun listAdvertisedCharacteristics(): List 14 | fun updateCharacteristicValue(uuid: String?, data: ByteArray?) 15 | fun notifyCharacteristic(uuid: String?) 16 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/grpc/server/GrpcEvents.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.grpc.server 2 | 3 | enum class GrpcCall { 4 | ListAvailableSimDevices, 5 | StartAdvertisement, 6 | StopAdvertisement, 7 | ListAdvertisedCharacteristics, 8 | UpdateCharacteristicValue, 9 | NotifyCharacteristic 10 | } 11 | 12 | interface GrpcEventListener { 13 | fun onGrpcServerStarted() 14 | fun onGrpcServerStopped() 15 | fun onGrpcCallReceived(event: GrpcCall) 16 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/grpc/server/GrpcServer.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.grpc.server 2 | 3 | import io.grpc.Server 4 | import io.grpc.netty.NettyServerBuilder 5 | import java.util.* 6 | 7 | class GrpcServer(private val port: Int = 8765) { 8 | var eventListener: GrpcEventListener? = null 9 | var actionHandler: GrpcActionHandler? = null 10 | private lateinit var server: Server 11 | 12 | private val nullActionHandler = object : GrpcActionHandler { 13 | override fun getSupportedDevices(): List { 14 | return emptyList() 15 | } 16 | 17 | override fun startAdvertisement(command: AdvertisementStartCommand): AdvertisementData { 18 | return AdvertisementData( 19 | advertisementName = "n/a", 20 | primaryServiceUUID = UUID.randomUUID(), 21 | isConnectable = false, 22 | isAdvertisingDeviceName = false 23 | ) 24 | } 25 | 26 | override fun stopAdvertisement() { 27 | // NOP 28 | } 29 | 30 | override fun listAdvertisedCharacteristics(): List { 31 | return emptyList() 32 | } 33 | 34 | override fun updateCharacteristicValue(uuid: String?, data: ByteArray?) { 35 | // NOP 36 | } 37 | 38 | override fun notifyCharacteristic(uuid: String?) { 39 | // NOP 40 | } 41 | } 42 | 43 | fun start() { 44 | server = NettyServerBuilder 45 | .forPort(port) 46 | .addService(createService()) 47 | .build() 48 | server.start() 49 | eventListener?.onGrpcServerStarted() 50 | } 51 | 52 | fun stop() { 53 | server.shutdown() 54 | eventListener?.onGrpcServerStopped() 55 | } 56 | 57 | private fun createService(): SimDeviceGrpcService { 58 | val handler: GrpcActionHandler = 59 | if (this.actionHandler == null) nullActionHandler else this.actionHandler!! 60 | return SimDeviceGrpcService(actionHandler = handler, eventListener = eventListener) 61 | } 62 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/grpc/server/SimDeviceGrpcService.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.grpc.server 2 | 3 | import android.util.Log 4 | import com.google.protobuf.Empty 5 | import io.grpc.stub.StreamObserver 6 | 7 | /** 8 | * class SimDeviceGrpcService 9 | * Handles incoming calls from gRPC clients. 10 | * When unable to compile, see "/grpc/generate_java.sh" for code generation 11 | */ 12 | class SimDeviceGrpcService( 13 | val actionHandler: GrpcActionHandler, 14 | private val eventListener: GrpcEventListener? 15 | ) : SimDeviceBLEGrpc.SimDeviceBLEImplBase() { 16 | override fun listAvailableSimDevices( 17 | request: Empty?, 18 | responseObserver: StreamObserver? 19 | ) { 20 | eventListener?.onGrpcCallReceived(GrpcCall.ListAvailableSimDevices) 21 | Log.i(TAG, "incoming request: listAvailableSimDevices") 22 | 23 | val allSimDevices = actionHandler.getSupportedDevices() 24 | 25 | val responseBuilder = ListAvailableSimDevicesResponse 26 | .newBuilder() 27 | .addAllAvailableDevices(allSimDevices) 28 | responseObserver?.onNext(responseBuilder.build()) 29 | responseObserver?.onCompleted() 30 | } 31 | 32 | override fun startAdvertisement( 33 | request: StartAdvertisementRequest?, 34 | responseObserver: StreamObserver? 35 | ) { 36 | eventListener?.onGrpcCallReceived(GrpcCall.StartAdvertisement) 37 | Log.i(TAG, "incoming request: startAdvertisement") 38 | 39 | try { 40 | val deviceName = request?.deviceName 41 | val connectable = request?.connectable 42 | val advertiseDeviceName = request?.advertiseDeviceName 43 | 44 | val command = AdvertisementStartCommand(deviceName, connectable, advertiseDeviceName) 45 | val advertisementData = actionHandler.startAdvertisement(command) 46 | 47 | val responseBuilder = StartAdvertisementResponse.newBuilder() 48 | .setAdvertisementName(advertisementData.advertisementName) 49 | .setIsConnectable(advertisementData.isConnectable) 50 | .setIsAdvertisingDeviceName(advertisementData.isAdvertisingDeviceName) 51 | .setPrimaryServiceUUID(advertisementData.primaryServiceUUID.toString()) 52 | responseObserver?.onNext(responseBuilder.build()) 53 | responseObserver?.onCompleted() 54 | } catch (t: Throwable) { 55 | t.printStackTrace() 56 | responseObserver?.onError(t) 57 | } 58 | } 59 | 60 | override fun stopAdvertisement( 61 | request: Empty?, 62 | responseObserver: StreamObserver? 63 | ) { 64 | eventListener?.onGrpcCallReceived(GrpcCall.StopAdvertisement) 65 | Log.i(TAG, "incoming request: stopAdvertisement") 66 | 67 | try { 68 | actionHandler.stopAdvertisement() 69 | 70 | val responseBuilder = Empty.newBuilder() 71 | responseObserver?.onNext(responseBuilder.build()) 72 | responseObserver?.onCompleted() 73 | } catch (t: Throwable) { 74 | responseObserver?.onError(t) 75 | } 76 | } 77 | 78 | override fun listAdvertisedCharacteristics( 79 | request: Empty?, 80 | responseObserver: StreamObserver? 81 | ) { 82 | eventListener?.onGrpcCallReceived(GrpcCall.ListAdvertisedCharacteristics) 83 | Log.i(TAG, "incoming request: listAdvertisedCharacteristics") 84 | 85 | try { 86 | val characteristics: List = 87 | actionHandler.listAdvertisedCharacteristics() 88 | 89 | val responseBuilder = ListAdvertisedCharacteristicsResponse.newBuilder() 90 | characteristics.forEach { 91 | responseBuilder.addAdvertisedCharacteristics(it) 92 | } 93 | 94 | responseObserver?.onNext(responseBuilder.build()) 95 | responseObserver?.onCompleted() 96 | } catch (t: Throwable) { 97 | responseObserver?.onError(t) 98 | } 99 | } 100 | 101 | override fun updateCharacteristicValue( 102 | request: UpdateCharacteristicValueRequest?, 103 | responseObserver: StreamObserver? 104 | ) { 105 | eventListener?.onGrpcCallReceived(GrpcCall.UpdateCharacteristicValue) 106 | Log.i(TAG, "incoming request: updateCharacteristicValue") 107 | 108 | try { 109 | val uuid = request?.uuid 110 | val updateValue = request?.updatedValue?.toByteArray() 111 | actionHandler.updateCharacteristicValue(uuid, updateValue) 112 | 113 | responseObserver?.onNext(Empty.newBuilder().build()) 114 | responseObserver?.onCompleted() 115 | } catch (t: Throwable) { 116 | responseObserver?.onError(t) 117 | } 118 | } 119 | 120 | override fun notifyCharacteristic( 121 | request: NotifyCharacteristicRequest?, 122 | responseObserver: StreamObserver? 123 | ) { 124 | eventListener?.onGrpcCallReceived(GrpcCall.NotifyCharacteristic) 125 | Log.i(TAG, "incoming request: notifyCharacteristic") 126 | 127 | try { 128 | val uuid = request?.uuid 129 | 130 | actionHandler.notifyCharacteristic(uuid) 131 | 132 | val responseBuilder = Empty.newBuilder() 133 | responseObserver?.onNext(responseBuilder.build()) 134 | responseObserver?.onCompleted() 135 | } catch (t: Throwable) { 136 | responseObserver?.onError(t) 137 | } 138 | } 139 | 140 | companion object { 141 | const val TAG = "SimDeviceGrpcService" 142 | } 143 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/battery/BatteryLevelCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.battery 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothBytesParser 5 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 6 | import java.util.* 7 | 8 | class BatteryLevelCharacteristic: Characteristic { 9 | override val name: String 10 | get() = "BatteryLevel" 11 | 12 | override val uuid: UUID 13 | get() = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb") 14 | 15 | override val type: Characteristic.Type 16 | get() = Characteristic.Type.Number 17 | 18 | override val isRead: Boolean 19 | get() = true 20 | 21 | override val isNotify: Boolean 22 | get() = true 23 | 24 | override val description: String? 25 | get() = BATTERY_LEVEL_DESCRIPTION 26 | 27 | override val initialValue: ByteArray? 28 | get() = convert(INITIAL_BATTERY_LEVEL) 29 | 30 | override fun validateWrite(offset: Int, value: ByteArray?): Int { 31 | value?.let { 32 | val batteryPercentage: Int = value[0].toInt() 33 | return if(batteryPercentage in 0..BATTERY_LEVEL_MAX) { 34 | BluetoothGatt.GATT_SUCCESS 35 | } else { 36 | BluetoothGatt.GATT_FAILURE 37 | } 38 | 39 | } 40 | return BluetoothGatt.GATT_FAILURE 41 | } 42 | 43 | override fun convertToPresentable(value: ByteArray): String { 44 | val parser = BluetoothBytesParser(value) 45 | val batteryValue = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT8) 46 | return "$batteryValue%" 47 | } 48 | 49 | override fun convertToBytes(value: String): ByteArray { 50 | val batteryValue: Int = value.toInt() 51 | return convert(batteryValue) 52 | } 53 | 54 | companion object { 55 | private const val INITIAL_BATTERY_LEVEL = 50 56 | private const val BATTERY_LEVEL_MAX = 100 57 | private const val BATTERY_LEVEL_DESCRIPTION = "The current charge level of a " + 58 | "battery. 100% represents fully charged while 0% represents fully discharged." 59 | } 60 | 61 | private fun convert(batteryPercentage: Int) : ByteArray { 62 | val parser = BluetoothBytesParser() 63 | parser.setIntValue(batteryPercentage, BluetoothBytesParser.FORMAT_UINT8) 64 | return parser.value 65 | } 66 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/battery/BatteryService.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.battery 2 | 3 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothUUID 4 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 5 | import nl.rwslinkman.simdeviceble.device.model.Service 6 | import java.util.* 7 | 8 | class BatteryService: Service { 9 | override val name: String 10 | get() = "BatteryService" 11 | 12 | override val uuid: UUID 13 | get() = SERVICE_UUID 14 | 15 | override val characteristics: List 16 | get() = listOf( 17 | BatteryLevelCharacteristic() 18 | ) 19 | 20 | companion object { 21 | private val SERVICE_UUID = BluetoothUUID.fromSigNumber("180F") 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/currenttime/CurrentTimeCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.currenttime 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothBytesParser 5 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothUUID 6 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 7 | import java.text.ParseException 8 | import java.text.SimpleDateFormat 9 | import java.util.* 10 | 11 | class CurrentTimeCharacteristic: Characteristic { 12 | override val name: String 13 | get() = "Current Time" 14 | override val uuid: UUID 15 | get() = BluetoothUUID.fromSigNumber("2A2B") 16 | override val type: Characteristic.Type 17 | get() = Characteristic.Type.Text 18 | override val isRead: Boolean 19 | get() = true 20 | override val isNotify: Boolean 21 | get() = true 22 | private val formatter = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.ENGLISH) 23 | 24 | override fun validateWrite(offset: Int, value: ByteArray?): Int { 25 | return BluetoothGatt.GATT_SUCCESS 26 | } 27 | 28 | override fun convertToPresentable(value: ByteArray): String { 29 | val parser = BluetoothBytesParser(value) 30 | val dateTime = parser.dateTime 31 | return formatter.format(dateTime) 32 | } 33 | 34 | override fun convertToBytes(value: String): ByteArray { 35 | val cal = Calendar.getInstance() 36 | try { 37 | val parsedDate = formatter.parse(value) 38 | cal.time = parsedDate!! 39 | } catch (ignored: ParseException) { 40 | // ignore and use phone's current datetime from Calendar.getInstance() 41 | } 42 | 43 | val parser = BluetoothBytesParser() 44 | parser.setDateTime(cal) 45 | return parser.value 46 | } 47 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/currenttime/CurrentTimeService.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.currenttime 2 | 3 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothUUID 4 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 5 | import nl.rwslinkman.simdeviceble.device.model.Service 6 | import java.util.* 7 | 8 | class CurrentTimeService: Service { 9 | override val name: String 10 | get() = "CurrentTimeService" 11 | 12 | override val uuid: UUID 13 | get() = SERVICE_UUID 14 | 15 | override val characteristics: List 16 | get() = listOf( 17 | CurrentTimeCharacteristic() 18 | ) 19 | 20 | companion object { 21 | val SERVICE_UUID = BluetoothUUID.fromSigNumber("1805") 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/deviceinformation/DeviceInformationService.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.deviceinformation 2 | 3 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothUUID 4 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 5 | import nl.rwslinkman.simdeviceble.device.model.Service 6 | import java.util.* 7 | 8 | class DeviceInformationService: Service { 9 | override val name: String 10 | get() = "DeviceInformationService" 11 | override val uuid: UUID 12 | get() = BluetoothUUID.fromSigNumber("180A") 13 | override val characteristics: List 14 | get() = listOf( 15 | ManufacturerNameCharacteristic(), 16 | ModelNumberCharacteristic(), 17 | SerialNumberCharacteristic(), 18 | HardwareRevisionCharacteristic(), 19 | FirmwareRevisionCharacteristic(), 20 | SoftwareRevisionCharacteristic(), 21 | SystemIdentifierCharacteristic(), 22 | RegulatoryCertificationCharacteristic(), 23 | PnpIdentifierCharacteristic() 24 | ) 25 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/deviceinformation/FirmwareRevisionCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.deviceinformation 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothUUID 5 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 6 | import java.util.* 7 | 8 | class FirmwareRevisionCharacteristic: Characteristic { 9 | override val name: String 10 | get() = "FirmwareRevision" 11 | override val uuid: UUID 12 | get() = BluetoothUUID.fromSigNumber("2A26") 13 | override val type: Characteristic.Type 14 | get() = Characteristic.Type.Text 15 | override val isRead: Boolean 16 | get() = true 17 | 18 | override fun validateWrite(offset: Int, value: ByteArray?): Int { 19 | return BluetoothGatt.GATT_SUCCESS 20 | } 21 | 22 | override fun convertToPresentable(value: ByteArray): String { 23 | return String(value) 24 | } 25 | 26 | override fun convertToBytes(value: String): ByteArray { 27 | return value.toByteArray() 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/deviceinformation/HardwareRevisionCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.deviceinformation 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothUUID 5 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 6 | import java.util.* 7 | 8 | class HardwareRevisionCharacteristic: Characteristic { 9 | override val name: String 10 | get() = "HardwareRevision" 11 | override val uuid: UUID 12 | get() = BluetoothUUID.fromSigNumber("2A27") 13 | override val type: Characteristic.Type 14 | get() = Characteristic.Type.Text 15 | override val isRead: Boolean 16 | get() = true 17 | 18 | override fun validateWrite(offset: Int, value: ByteArray?): Int { 19 | return BluetoothGatt.GATT_SUCCESS 20 | } 21 | 22 | override fun convertToPresentable(value: ByteArray): String { 23 | return String(value) 24 | } 25 | 26 | override fun convertToBytes(value: String): ByteArray { 27 | return value.toByteArray() 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/deviceinformation/ManufacturerNameCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.deviceinformation 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothUUID 5 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 6 | import java.util.* 7 | 8 | class ManufacturerNameCharacteristic: Characteristic { 9 | override val name: String 10 | get() = "ManufacturerName" 11 | override val uuid: UUID 12 | get() = BluetoothUUID.fromSigNumber("2A29") 13 | override val type: Characteristic.Type 14 | get() = Characteristic.Type.Text 15 | override val isRead: Boolean 16 | get() = true 17 | 18 | override fun validateWrite(offset: Int, value: ByteArray?): Int { 19 | return BluetoothGatt.GATT_SUCCESS 20 | } 21 | 22 | override fun convertToPresentable(value: ByteArray): String { 23 | return String(value) 24 | } 25 | 26 | override fun convertToBytes(value: String): ByteArray { 27 | return value.toByteArray() 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/deviceinformation/ModelNumberCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.deviceinformation 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothUUID 5 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 6 | import java.util.* 7 | 8 | class ModelNumberCharacteristic: Characteristic { 9 | override val name: String 10 | get() = "ModelNumber" 11 | override val uuid: UUID 12 | get() = BluetoothUUID.fromSigNumber("2A24") 13 | override val type: Characteristic.Type 14 | get() = Characteristic.Type.Text 15 | override val isRead: Boolean 16 | get() = true 17 | 18 | override fun validateWrite(offset: Int, value: ByteArray?): Int { 19 | return BluetoothGatt.GATT_SUCCESS 20 | } 21 | 22 | override fun convertToPresentable(value: ByteArray): String { 23 | return String(value) 24 | } 25 | 26 | override fun convertToBytes(value: String): ByteArray { 27 | return value.toByteArray() 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/deviceinformation/PnpIdentifierCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.deviceinformation 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothUUID 5 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 6 | import java.util.* 7 | 8 | class PnpIdentifierCharacteristic: Characteristic { 9 | override val name: String 10 | get() = "PnpIdentifier" 11 | override val uuid: UUID 12 | get() = BluetoothUUID.fromSigNumber("2A50") 13 | override val type: Characteristic.Type 14 | get() = Characteristic.Type.Text 15 | override val isRead: Boolean 16 | get() = true 17 | 18 | override fun validateWrite(offset: Int, value: ByteArray?): Int { 19 | return BluetoothGatt.GATT_SUCCESS 20 | } 21 | 22 | override fun convertToPresentable(value: ByteArray): String { 23 | return String(value) 24 | } 25 | 26 | override fun convertToBytes(value: String): ByteArray { 27 | return value.toByteArray() 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/deviceinformation/RegulatoryCertificationCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.deviceinformation 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothUUID 5 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 6 | import java.util.* 7 | 8 | class RegulatoryCertificationCharacteristic: Characteristic { 9 | override val name: String 10 | get() = "RegulatoryCertification" 11 | override val uuid: UUID 12 | get() = BluetoothUUID.fromSigNumber("2A2A") 13 | override val type: Characteristic.Type 14 | get() = Characteristic.Type.Text 15 | override val isRead: Boolean 16 | get() = true 17 | 18 | override fun validateWrite(offset: Int, value: ByteArray?): Int { 19 | return BluetoothGatt.GATT_SUCCESS 20 | } 21 | 22 | override fun convertToPresentable(value: ByteArray): String { 23 | return String(value) 24 | } 25 | 26 | override fun convertToBytes(value: String): ByteArray { 27 | return value.toByteArray() 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/deviceinformation/SerialNumberCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.deviceinformation 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothUUID 5 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 6 | import java.util.* 7 | 8 | class SerialNumberCharacteristic: Characteristic { 9 | override val name: String 10 | get() = "SerialNumber" 11 | override val uuid: UUID 12 | get() = BluetoothUUID.fromSigNumber("2A25") 13 | override val type: Characteristic.Type 14 | get() = Characteristic.Type.Text 15 | override val isRead: Boolean 16 | get() = true 17 | 18 | override fun validateWrite(offset: Int, value: ByteArray?): Int { 19 | return BluetoothGatt.GATT_SUCCESS 20 | } 21 | 22 | override fun convertToPresentable(value: ByteArray): String { 23 | return String(value) 24 | } 25 | 26 | override fun convertToBytes(value: String): ByteArray { 27 | return value.toByteArray() 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/deviceinformation/SoftwareRevisionCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.deviceinformation 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothUUID 5 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 6 | import java.util.* 7 | 8 | class SoftwareRevisionCharacteristic: Characteristic { 9 | override val name: String 10 | get() = "SoftwareRevision" 11 | override val uuid: UUID 12 | get() = BluetoothUUID.fromSigNumber("2A28") 13 | override val type: Characteristic.Type 14 | get() = Characteristic.Type.Text 15 | override val isRead: Boolean 16 | get() = true 17 | 18 | override fun validateWrite(offset: Int, value: ByteArray?): Int { 19 | return BluetoothGatt.GATT_SUCCESS 20 | } 21 | 22 | override fun convertToPresentable(value: ByteArray): String { 23 | return String(value) 24 | } 25 | 26 | override fun convertToBytes(value: String): ByteArray { 27 | return value.toByteArray() 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/deviceinformation/SystemIdentifierCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.deviceinformation 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothUUID 5 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 6 | import java.util.* 7 | 8 | class SystemIdentifierCharacteristic: Characteristic { 9 | override val name: String 10 | get() = "SystemIdentifier" 11 | override val uuid: UUID 12 | get() = BluetoothUUID.fromSigNumber("2A23") 13 | override val type: Characteristic.Type 14 | get() = Characteristic.Type.Text 15 | override val isRead: Boolean 16 | get() = true 17 | 18 | override fun validateWrite(offset: Int, value: ByteArray?): Int { 19 | return BluetoothGatt.GATT_SUCCESS 20 | } 21 | 22 | override fun convertToPresentable(value: ByteArray): String { 23 | return String(value) 24 | } 25 | 26 | override fun convertToBytes(value: String): ByteArray { 27 | return value.toByteArray() 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/healththermometer/HealthThermometerService.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.healththermometer 2 | 3 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 4 | import nl.rwslinkman.simdeviceble.device.model.Service 5 | import java.util.* 6 | 7 | /** 8 | * See [Health Thermometer Service](https://developer.bluetooth.org/gatt/services/Pages/ServiceViewer.aspx?u=org.bluetooth.service.health_thermometer.xml) 9 | * This service exposes two characteristics with descriptors: 10 | * - Measurement Interval Characteristic: 11 | * - Listen to notifications to from which you can subscribe to notifications 12 | * - CCCD Descriptor: 13 | * - Read/Write to get/set notifications. 14 | * - User Description Descriptor: 15 | * - Read/Write to get/set the description of the Characteristic. 16 | * - Temperature Measurement Characteristic: 17 | * - Read value to get the current interval of the temperature measurement timer. 18 | * - Write value resets the temperature measurement timer with the new value. This timer 19 | * is responsible for triggering value changed events every "Measurement Interval" value. 20 | * - CCCD Descriptor: 21 | * - Read/Write to get/set notifications. 22 | * - User Description Descriptor: 23 | * - Read/Write to get/set the description of the Characteristic. 24 | */ 25 | class HealthThermometerService: Service { 26 | override val name: String 27 | get() = "HealthThermometerService" 28 | 29 | override val uuid: UUID 30 | get() = SERVICE_UUID 31 | 32 | override val characteristics: List 33 | get() = listOf( 34 | TemperatureMeasurementCharacteristic(), 35 | MeasurementIntervalCharacteristic() 36 | ) 37 | 38 | companion object { 39 | val SERVICE_UUID: UUID = UUID.fromString("00001809-0000-1000-8000-00805f9b34fb") 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/healththermometer/MeasurementIntervalCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.healththermometer 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 5 | import java.nio.ByteBuffer 6 | import java.nio.ByteOrder 7 | import java.util.* 8 | import kotlin.math.pow 9 | 10 | /** 11 | * Measurement Interval 12 | */ 13 | class MeasurementIntervalCharacteristic: Characteristic { 14 | override val name: String 15 | get() = "MeasurementInterval" 16 | 17 | override val uuid: UUID 18 | get() = UUID.fromString("00002A21-0000-1000-8000-00805f9b34fb") 19 | 20 | override val type: Characteristic.Type 21 | get() = Characteristic.Type.Number 22 | 23 | override val isRead: Boolean 24 | get() = true 25 | 26 | override val isWrite: Boolean 27 | get() = true 28 | 29 | override val isIndicate: Boolean 30 | get() = true 31 | 32 | override val description: String? 33 | get() = MEASUREMENT_INTERVAL_DESCRIPTION 34 | 35 | override val initialValue: ByteArray? 36 | get() = convertToBytes(INITIAL_MEASUREMENT_INTERVAL.toString()) 37 | 38 | override fun validateWrite(offset: Int, value: ByteArray?): Int { 39 | if (offset != 0) { 40 | return BluetoothGatt.GATT_INVALID_OFFSET 41 | } 42 | 43 | value?.let { 44 | if(it.size != 2) { 45 | return BluetoothGatt.GATT_INVALID_ATTRIBUTE_LENGTH 46 | } 47 | // Parse byte to numeric value 48 | val byteBuffer: ByteBuffer = ByteBuffer.wrap(value) 49 | byteBuffer.order(ByteOrder.LITTLE_ENDIAN) 50 | val newMeasurementIntervalValue: Short = byteBuffer.short 51 | 52 | // Check value limits and return 53 | return if (!isValueWithinLimits(newMeasurementIntervalValue)) { 54 | BluetoothGatt.GATT_FAILURE 55 | } else BluetoothGatt.GATT_SUCCESS 56 | } ?: return BluetoothGatt.GATT_INVALID_ATTRIBUTE_LENGTH 57 | } 58 | 59 | override fun convertToPresentable(value: ByteArray): String { 60 | val buffer = ByteBuffer.allocate(Int.SIZE_BYTES) 61 | buffer.put(value) 62 | buffer.flip() 63 | val interval = buffer.int 64 | return "$interval second(s)" 65 | } 66 | 67 | override fun convertToBytes(value: String): ByteArray { 68 | val newLevel = Integer.parseInt(value) 69 | val buffer: ByteBuffer = ByteBuffer.allocate(Int.SIZE_BYTES) 70 | buffer.putInt(newLevel) 71 | return buffer.array() 72 | } 73 | 74 | private fun isValueWithinLimits(value: Short): Boolean { 75 | return (value >= MIN_MEASUREMENT_INTERVAL) && (value <= MAX_MEASUREMENT_INTERVAL) 76 | } 77 | 78 | companion object { 79 | private const val INITIAL_MEASUREMENT_INTERVAL = 1 80 | private const val MIN_MEASUREMENT_INTERVAL = 1 81 | private val MAX_MEASUREMENT_INTERVAL = 2.0.pow(16.0).toInt() - 1 82 | private const val MEASUREMENT_INTERVAL_DESCRIPTION = "This characteristic is used " + 83 | "to enable and control the interval between consecutive temperature measurements." 84 | } 85 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/healththermometer/TemperatureMeasurementCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.healththermometer 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothBytesParser 5 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 6 | import java.util.* 7 | 8 | /** 9 | * Temperature Measurement 10 | */ 11 | class TemperatureMeasurementCharacteristic : Characteristic { 12 | override val name: String 13 | get() = "TemperatureMeasurement" 14 | 15 | override val uuid: UUID 16 | get() = UUID.fromString("00002A1C-0000-1000-8000-00805f9b34fb") 17 | 18 | override val type: Characteristic.Type 19 | get() = Characteristic.Type.Decimal 20 | 21 | override val isRead: Boolean 22 | get() = true 23 | 24 | override val isNotify: Boolean 25 | get() = true 26 | 27 | override val isIndicate: Boolean 28 | get() = true 29 | 30 | override val description: String? 31 | get() = TEMPERATURE_MEASUREMENT_DESCRIPTION 32 | 33 | override val initialValue: ByteArray? 34 | get() = convert(INITIAL_TEMPERATURE_MEASUREMENT_VALUE) 35 | 36 | override fun validateWrite(offset: Int, value: ByteArray?): Int { 37 | return BluetoothGatt.GATT_SUCCESS 38 | } 39 | 40 | override fun convertToPresentable(value: ByteArray): String { 41 | val parser = BluetoothBytesParser(value) 42 | val temperature = parser.getFloatValue(TEMPERATURE_MEASUREMENT_VALUE_FORMAT) 43 | return temperature.toString() 44 | } 45 | 46 | override fun convertToBytes(value: String): ByteArray { 47 | val temperature: Float = value.toFloat() 48 | return convert(temperature) 49 | } 50 | 51 | companion object { 52 | private const val TEMPERATURE_MEASUREMENT_VALUE_FORMAT = BluetoothBytesParser.FORMAT_FLOAT 53 | private const val INITIAL_TEMPERATURE_MEASUREMENT_VALUE: Float = 37.0f 54 | private const val EXPONENT_MASK = 0x7f800000 55 | private const val EXPONENT_SHIFT = 23 56 | private const val MANTISSA_MASK = 0x007fffff 57 | private const val MANTISSA_SHIFT = 0 58 | private const val TEMPERATURE_MEASUREMENT_DESCRIPTION = "This characteristic is used to send a temperature measurement." 59 | } 60 | 61 | private fun convert(value: Float): ByteArray { 62 | val parser = BluetoothBytesParser() 63 | parser.setFloatValue(value, 1) 64 | return parser.value 65 | } 66 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/heartrate/BodySensorLocationCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.heartrate 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothBytesParser 5 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 6 | import java.util.* 7 | 8 | /** 9 | * Body Sensor Location 10 | */ 11 | class BodySensorLocationCharacteristic: Characteristic { 12 | override val name: String 13 | get() = "BodySensorLocation" 14 | 15 | override val uuid: UUID 16 | get() = CHAR_UUID 17 | 18 | override val type: Characteristic.Type 19 | get() = Characteristic.Type.Number 20 | 21 | override val isRead: Boolean 22 | get() = true 23 | 24 | override fun validateWrite(offset: Int, value: ByteArray?): Int { 25 | return BluetoothGatt.GATT_SUCCESS 26 | } 27 | 28 | override fun convertToPresentable(value: ByteArray): String { 29 | val parser = BluetoothBytesParser(value) 30 | val locationCode = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT8) 31 | val locationName = when (locationCode) { 32 | 1 -> "Chest" 33 | 2 -> "Wrist" 34 | 3 -> "Finger" 35 | 4 -> "Hand" 36 | 5 -> "Ear lobe" 37 | 6 -> "Foot" 38 | else -> "Other" 39 | } 40 | return "$locationName ($locationCode)" 41 | } 42 | 43 | override fun convertToBytes(value: String): ByteArray { 44 | val retVal = ByteArray(1) 45 | retVal[0] = value.toByte() 46 | return retVal 47 | } 48 | 49 | companion object { 50 | val CHAR_UUID: UUID = UUID.fromString("00002A38-0000-1000-8000-00805f9b34fb") 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/heartrate/HeartRateControlPointCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.heartrate 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import android.bluetooth.BluetoothGattCharacteristic 5 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothBytesParser 6 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 7 | import java.util.* 8 | 9 | /** 10 | * Heart Rate Control Point 11 | */ 12 | class HeartRateControlPointCharacteristic: Characteristic { 13 | override val name: String 14 | get() = "HeartRateControlPoint" 15 | 16 | override val uuid: UUID 17 | get() = CHAR_UUID 18 | 19 | override val type: Characteristic.Type 20 | get() = Characteristic.Type.Number 21 | 22 | override val isWrite: Boolean 23 | get() = true 24 | 25 | override val initialValue: ByteArray? 26 | get() = convert(INITIAL_EXPENDED_ENERGY) 27 | 28 | override fun validateWrite(offset: Int, value: ByteArray?): Int { 29 | return BluetoothGatt.GATT_SUCCESS 30 | } 31 | 32 | override fun convertToPresentable(value: ByteArray): String { 33 | val parser = BluetoothBytesParser(value) 34 | val controlPoint: Int = parser.getIntValue(EXPENDED_ENERGY_FORMAT) 35 | return controlPoint.toString() 36 | } 37 | 38 | override fun convertToBytes(value: String): ByteArray { 39 | val controlPoint = Integer.parseInt(value) 40 | return convert(controlPoint) 41 | } 42 | 43 | private fun convert(controlPoint: Int): ByteArray { 44 | val parser = BluetoothBytesParser() 45 | parser.setIntValue(controlPoint, EXPENDED_ENERGY_FORMAT) 46 | return parser.value 47 | } 48 | 49 | companion object { 50 | val CHAR_UUID: UUID = UUID.fromString("00002A39-0000-1000-8000-00805f9b34fb") 51 | private const val EXPENDED_ENERGY_FORMAT = BluetoothGattCharacteristic.FORMAT_UINT16 52 | private const val INITIAL_EXPENDED_ENERGY = 0 53 | } 54 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/heartrate/HeartRateMeasurementCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.heartrate 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import nl.rwslinkman.simdeviceble.bluetooth.BluetoothBytesParser 5 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 6 | import java.util.* 7 | 8 | /** 9 | * Heart Rate Measurement 10 | */ 11 | class HeartRateMeasurementCharacteristic: Characteristic { 12 | override val name: String 13 | get() = "HeartRateMeasurement" 14 | 15 | override val uuid: UUID 16 | get() = CHAR_UUID 17 | 18 | override val type: Characteristic.Type 19 | get() = Characteristic.Type.Number 20 | 21 | override val isRead: Boolean 22 | get() = true 23 | 24 | override val isNotify: Boolean 25 | get() = true 26 | 27 | override val description: String? 28 | get() = HEART_RATE_MEASUREMENT_DESCRIPTION 29 | 30 | override val initialValue: ByteArray? 31 | get() = convert(INITIAL_HEART_RATE_MEASUREMENT_VALUE) 32 | 33 | override fun validateWrite(offset: Int, value: ByteArray?): Int { 34 | return BluetoothGatt.GATT_SUCCESS 35 | } 36 | 37 | override fun convertToPresentable(value: ByteArray): String { 38 | val parser = BluetoothBytesParser(value) 39 | val heartRate: Int = parser.getIntValue(HEART_RATE_MEASUREMENT_VALUE_FORMAT) 40 | return heartRate.toString() 41 | } 42 | 43 | override fun convertToBytes(value: String): ByteArray { 44 | val heartRate = Integer.parseInt(value) 45 | return convert(heartRate) 46 | } 47 | 48 | companion object { 49 | val CHAR_UUID: UUID = UUID.fromString("00002A37-0000-1000-8000-00805f9b34fb") 50 | private const val HEART_RATE_MEASUREMENT_VALUE_FORMAT = BluetoothBytesParser.FORMAT_UINT8 51 | private const val INITIAL_HEART_RATE_MEASUREMENT_VALUE = 60 52 | private const val HEART_RATE_MEASUREMENT_DESCRIPTION = "Used to send a heart rate measurement" 53 | } 54 | 55 | private fun convert(value: Int): ByteArray { 56 | val parser = BluetoothBytesParser() 57 | parser.setIntValue(value, HEART_RATE_MEASUREMENT_VALUE_FORMAT) 58 | return parser.value 59 | } 60 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/service/heartrate/HeartRateService.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.service.heartrate 2 | 3 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 4 | import nl.rwslinkman.simdeviceble.device.model.Service 5 | import java.util.* 6 | 7 | /** 8 | * See [ 9 | * Heart Rate Service](https://developer.bluetooth.org/gatt/services/Pages/ServiceViewer.aspx?u=org.bluetooth.service.heart_rate.xml) 10 | */ 11 | class HeartRateService: Service { 12 | override val name: String 13 | get() = "HeartRateService" 14 | 15 | override val uuid: UUID 16 | get() = SERVICE_UUID 17 | 18 | override val characteristics: List 19 | get() = listOf( 20 | HeartRateMeasurementCharacteristic(), 21 | BodySensorLocationCharacteristic(), 22 | HeartRateControlPointCharacteristic() 23 | ) 24 | 25 | companion object { 26 | val SERVICE_UUID: UUID = UUID.fromString("0000180D-0000-1000-8000-00805f9b34fb") 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/ui/data/CharacteristicDataViewHolder.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.ui.data 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import android.widget.TextView 7 | import nl.rwslinkman.simdeviceble.R 8 | 9 | internal class CharacteristicDataViewHolder(inflater: LayoutInflater) { 10 | val itemView: View = inflater.inflate(R.layout.list_item_characteristic, null) 11 | val nameView: TextView = itemView.findViewById(R.id.item_characteristic_name) 12 | val uuidView: TextView = itemView.findViewById(R.id.item_characteristic_uuid) 13 | val valueView: TextView = itemView.findViewById(R.id.item_characteristic_value) 14 | val updateBlock: ViewGroup = itemView.findViewById(R.id.item_characteristic_updates_block) 15 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/ui/data/ServiceDataAdapter.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.ui.data 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import androidx.recyclerview.widget.RecyclerView 7 | import nl.rwslinkman.simdeviceble.R 8 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 9 | import nl.rwslinkman.simdeviceble.device.model.Service 10 | import nl.rwslinkman.simdeviceble.ui.data.controls.CharacteristicControls 11 | import nl.rwslinkman.simdeviceble.ui.data.controls.DecimalCharacteristicControls 12 | import nl.rwslinkman.simdeviceble.ui.data.controls.NumberCharacteristicControls 13 | import nl.rwslinkman.simdeviceble.ui.data.controls.TextCharacteristicControls 14 | import java.util.* 15 | 16 | class ServiceDataAdapter(private val listener: CharacteristicManipulationListener): RecyclerView.Adapter() { 17 | 18 | interface CharacteristicManipulationListener { 19 | fun setCharacteristicValue(characteristic: Characteristic, setValue: Any) 20 | 21 | fun notifyCharacteristic(characteristic: Characteristic) 22 | } 23 | 24 | private val dataSet: MutableList = mutableListOf() 25 | private val dataContainer: MutableMap = mutableMapOf() 26 | 27 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ServiceDataViewHolder { 28 | val itemRoot: View = LayoutInflater.from(parent.context) 29 | .inflate(R.layout.list_item_servicedata, parent, false) 30 | return ServiceDataViewHolder(itemRoot) 31 | } 32 | 33 | override fun getItemCount(): Int { 34 | return dataSet.size 35 | } 36 | 37 | override fun onBindViewHolder(holder: ServiceDataViewHolder, position: Int) { 38 | val serviceItem: Service = dataSet[position] 39 | 40 | holder.nameView.text = serviceItem.name 41 | holder.uuidView.text = serviceItem.uuid.toString() 42 | 43 | holder.characteristicsBlock.removeAllViews() 44 | val inflater = LayoutInflater.from(holder.itemView.context) 45 | val characteristics = serviceItem.characteristics 46 | characteristics.forEach { charItem -> 47 | // Dynamically add child views 48 | val charViewHolder = CharacteristicDataViewHolder(inflater) 49 | charViewHolder.nameView.text = charItem.name 50 | charViewHolder.uuidView.text = charItem.uuid.toString() 51 | 52 | val charValue: String = dataContainer[charItem.uuid] ?: "n/a" 53 | charViewHolder.valueView.text = charValue 54 | 55 | var updateControls: CharacteristicControls? = null 56 | if (charItem.isRead) { 57 | updateControls = when(charItem.type) { 58 | Characteristic.Type.Number -> NumberCharacteristicControls() 59 | Characteristic.Type.Decimal -> DecimalCharacteristicControls() 60 | else -> TextCharacteristicControls() 61 | } 62 | } 63 | 64 | updateControls?.let { 65 | val controlsView: View = inflater.inflate(it.controlsLayoutId, charViewHolder.updateBlock) 66 | it.setup(controlsView) 67 | it.bind(charItem, listener) 68 | } 69 | 70 | holder.characteristicsBlock.addView(charViewHolder.itemView) 71 | } 72 | } 73 | 74 | fun updateDataSet(dataSet: List, dataContainer: Map) { 75 | this.dataSet.clear() 76 | this.dataSet.addAll(dataSet) 77 | this.dataContainer.clear() 78 | this.dataContainer.putAll(dataContainer) 79 | this.notifyDataSetChanged() 80 | } 81 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/ui/data/ServiceDataFragment.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.ui.data 2 | 3 | import android.os.Bundle 4 | import android.text.Editable 5 | import android.util.Log 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import android.widget.RelativeLayout 10 | import android.widget.TextView 11 | import androidx.fragment.app.Fragment 12 | import androidx.fragment.app.activityViewModels 13 | import androidx.lifecycle.Observer 14 | import androidx.recyclerview.widget.LinearLayoutManager 15 | import androidx.recyclerview.widget.RecyclerView 16 | import nl.rwslinkman.simdeviceble.AppModel 17 | import nl.rwslinkman.simdeviceble.R 18 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 19 | import nl.rwslinkman.simdeviceble.device.model.Device 20 | import java.util.* 21 | 22 | class ServiceDataFragment : Fragment() { 23 | 24 | private val manipulationListener = object : ServiceDataAdapter.CharacteristicManipulationListener { 25 | override fun setCharacteristicValue(characteristic: Characteristic, setValue: Any) { 26 | Log.d(TAG, "setCharacteristicValue: set value to $setValue") 27 | appModel.updateCharacteristicValue(characteristic, setValue.toString()) 28 | } 29 | 30 | override fun notifyCharacteristic(characteristic: Characteristic) { 31 | Log.d(TAG, "notifyCharacteristic: send notification") 32 | appModel.sendCharacteristicNotification(characteristic) 33 | } 34 | 35 | } 36 | private val appModel: AppModel by activityViewModels() 37 | private val servicesAdapter = ServiceDataAdapter(manipulationListener) 38 | 39 | override fun onCreateView( 40 | inflater: LayoutInflater, 41 | container: ViewGroup?, 42 | savedInstanceState: Bundle? 43 | ): View? { 44 | val root = inflater.inflate(R.layout.fragment_servicedata, container, false) 45 | 46 | val notAdvertisingView: TextView = root.findViewById(R.id.block_not_advertising) 47 | val servicesView: RelativeLayout = root.findViewById(R.id.block_services) 48 | 49 | // List with subscription to auto update itself 50 | root.findViewById(R.id.servicedata_list).apply { 51 | setHasFixedSize(true) 52 | layoutManager = LinearLayoutManager(context) 53 | adapter = servicesAdapter 54 | } 55 | appModel.activeDevice.observe(this, Observer { 56 | showDeviceServices() 57 | }) 58 | appModel.presentableDataContainer.observe(this, Observer { 59 | showDeviceServices() 60 | }) 61 | 62 | // Toggle view visibility 63 | appModel.isAdvertising.observe(this, Observer { 64 | notAdvertisingView.visibility = if (it) View.GONE else View.VISIBLE 65 | servicesView.visibility = if (it) View.VISIBLE else View.GONE 66 | }) 67 | return root 68 | } 69 | 70 | private fun showDeviceServices() { 71 | val isAdvertising: Boolean = appModel.isAdvertising.value ?: false 72 | if (!isAdvertising) { 73 | return 74 | } 75 | 76 | val activeDevice: Device = appModel.activeDevice.value!! 77 | val currentData: Map = appModel.presentableDataContainer.value!! 78 | 79 | servicesAdapter.updateDataSet(activeDevice.services, currentData) 80 | } 81 | 82 | companion object { 83 | const val TAG = "ServiceDataFragment" 84 | } 85 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/ui/data/ServiceDataViewHolder.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.ui.data 2 | 3 | import android.view.View 4 | import android.view.ViewGroup 5 | import android.widget.TextView 6 | import androidx.recyclerview.widget.RecyclerView 7 | import nl.rwslinkman.simdeviceble.R 8 | 9 | class ServiceDataViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { 10 | val nameView: TextView = itemView.findViewById(R.id.item_service_name) 11 | val uuidView: TextView = itemView.findViewById(R.id.item_service_uuid) 12 | val characteristicsBlock: ViewGroup = itemView.findViewById(R.id.item_service_characteristics) 13 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/ui/data/controls/CharacteristicControls.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.ui.data.controls 2 | 3 | import android.view.View 4 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 5 | import nl.rwslinkman.simdeviceble.ui.data.ServiceDataAdapter 6 | 7 | interface CharacteristicControls { 8 | val controlsLayoutId: Int 9 | 10 | fun setup(controlsView: View) 11 | 12 | fun bind(charItem: Characteristic, listener: ServiceDataAdapter.CharacteristicManipulationListener) 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/ui/data/controls/DecimalCharacteristicControls.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.ui.data.controls 2 | 3 | import android.text.Editable 4 | import android.view.View 5 | import android.widget.Button 6 | import android.widget.EditText 7 | import nl.rwslinkman.simdeviceble.R 8 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 9 | import nl.rwslinkman.simdeviceble.ui.data.ServiceDataAdapter 10 | 11 | class DecimalCharacteristicControls: CharacteristicControls { 12 | 13 | private lateinit var valueControl: EditText 14 | private lateinit var valueUpdateButton: Button 15 | private lateinit var notifyButton: Button 16 | 17 | override val controlsLayoutId: Int 18 | get() = R.layout.characteristic_update_control_decimal 19 | 20 | override fun setup(controlsView: View) { 21 | valueControl = controlsView.findViewById(R.id.characterstic_control_edittext) 22 | valueUpdateButton = controlsView.findViewById(R.id.characteristic_control_set_btn) 23 | notifyButton = controlsView.findViewById(R.id.characteristic_control_notify_btn) 24 | } 25 | 26 | override fun bind(charItem: Characteristic, listener: ServiceDataAdapter.CharacteristicManipulationListener) { 27 | valueUpdateButton.setOnClickListener { 28 | val fieldValue: Editable = valueControl.text 29 | listener.setCharacteristicValue(charItem, fieldValue) 30 | } 31 | 32 | notifyButton.isEnabled = charItem.isNotify 33 | notifyButton.setOnClickListener { 34 | listener.notifyCharacteristic(charItem) 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/ui/data/controls/NumberCharacteristicControls.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.ui.data.controls 2 | 3 | import android.text.Editable 4 | import android.view.View 5 | import android.widget.Button 6 | import android.widget.EditText 7 | import nl.rwslinkman.simdeviceble.R 8 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 9 | import nl.rwslinkman.simdeviceble.ui.data.ServiceDataAdapter 10 | 11 | class NumberCharacteristicControls: CharacteristicControls { 12 | 13 | private lateinit var valueControl: EditText 14 | private lateinit var valueUpdateButton: Button 15 | private lateinit var notifyButton: Button 16 | 17 | override val controlsLayoutId: Int 18 | get() = R.layout.characteristic_update_control_number 19 | 20 | override fun setup(controlsView: View) { 21 | valueControl = controlsView.findViewById(R.id.characterstic_control_edittext) 22 | valueUpdateButton = controlsView.findViewById(R.id.characteristic_control_set_btn) 23 | notifyButton = controlsView.findViewById(R.id.characteristic_control_notify_btn) 24 | } 25 | 26 | override fun bind(charItem: Characteristic, listener: ServiceDataAdapter.CharacteristicManipulationListener) { 27 | valueUpdateButton.setOnClickListener { 28 | val fieldValue: Editable = valueControl.text 29 | listener.setCharacteristicValue(charItem, fieldValue) 30 | } 31 | 32 | notifyButton.isEnabled = charItem.isNotify 33 | notifyButton.setOnClickListener { 34 | listener.notifyCharacteristic(charItem) 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/ui/data/controls/TextCharacteristicControls.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.ui.data.controls 2 | 3 | import android.text.Editable 4 | import android.view.View 5 | import android.widget.Button 6 | import android.widget.EditText 7 | import nl.rwslinkman.simdeviceble.R 8 | import nl.rwslinkman.simdeviceble.device.model.Characteristic 9 | import nl.rwslinkman.simdeviceble.ui.data.ServiceDataAdapter 10 | 11 | class TextCharacteristicControls: CharacteristicControls { 12 | 13 | private lateinit var valueControl: EditText 14 | private lateinit var valueUpdateButton: Button 15 | private lateinit var notifyButton: Button 16 | 17 | override val controlsLayoutId: Int 18 | get() = R.layout.characteristic_update_control_text 19 | 20 | override fun setup(controlsView: View) { 21 | valueControl = controlsView.findViewById(R.id.characterstic_control_edittext) 22 | valueUpdateButton = controlsView.findViewById(R.id.characteristic_control_set_btn) 23 | notifyButton = controlsView.findViewById(R.id.characteristic_control_notify_btn) 24 | } 25 | 26 | override fun bind(charItem: Characteristic, listener: ServiceDataAdapter.CharacteristicManipulationListener) { 27 | valueUpdateButton.setOnClickListener { 28 | val fieldValue: Editable = valueControl.text 29 | listener.setCharacteristicValue(charItem, fieldValue) 30 | } 31 | 32 | notifyButton.isEnabled = charItem.isNotify 33 | notifyButton.setOnClickListener { 34 | listener.notifyCharacteristic(charItem) 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/ui/devices/SupportedDevicesAdapter.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.ui.devices 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import android.widget.LinearLayout 7 | import android.widget.TextView 8 | import androidx.recyclerview.widget.RecyclerView 9 | import nl.rwslinkman.simdeviceble.R 10 | import nl.rwslinkman.simdeviceble.device.model.Device 11 | 12 | class SupportedDevicesAdapter(private val dataSet: List): RecyclerView.Adapter() { 13 | 14 | class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { 15 | val nameView: TextView = itemView.findViewById(R.id.item_device_name) 16 | val servicesView: LinearLayout = itemView.findViewById(R.id.item_device_services) 17 | } 18 | 19 | private class ServiceViewHolder(inflater: LayoutInflater) { 20 | val itemView: View = inflater.inflate(R.layout.list_item_servicedata, null) 21 | val nameView: TextView = itemView.findViewById(R.id.item_service_name) 22 | val uuidView: TextView = itemView.findViewById(R.id.item_service_uuid) 23 | val characteristicsBlock: LinearLayout = itemView.findViewById(R.id.item_service_characteristics) 24 | } 25 | 26 | private class CharacteristicViewHolder(inflater: LayoutInflater) { 27 | val itemView: View = inflater.inflate(R.layout.list_item_supported_characteristic, null) 28 | val nameView: TextView = itemView.findViewById(R.id.item_characteristic_name) 29 | val uuidView: TextView = itemView.findViewById(R.id.item_characteristic_uuid) 30 | } 31 | 32 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 33 | val itemRoot: View = LayoutInflater.from(parent.context) 34 | .inflate(R.layout.list_item_supported_device, parent, false) 35 | return ViewHolder(itemRoot) 36 | } 37 | 38 | override fun getItemCount(): Int { 39 | return dataSet.size 40 | } 41 | 42 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 43 | val deviceItem: Device = dataSet[position] 44 | 45 | holder.nameView.text = deviceItem.name 46 | 47 | val inflater = LayoutInflater.from(holder.itemView.context) 48 | deviceItem.services.forEach { serviceItem -> 49 | val serviceViewHolder = ServiceViewHolder(inflater) 50 | 51 | serviceViewHolder.nameView.text = serviceItem.name 52 | serviceViewHolder.uuidView.text = serviceItem.uuid.toString() 53 | 54 | serviceItem.characteristics.forEach { 55 | val charViewHolder = CharacteristicViewHolder(inflater) 56 | 57 | charViewHolder.nameView.text = it.name 58 | charViewHolder.uuidView.text = it.uuid.toString() 59 | 60 | serviceViewHolder.characteristicsBlock.addView(charViewHolder.itemView) 61 | } 62 | 63 | holder.servicesView.addView(serviceViewHolder.itemView) 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/ui/devices/SupportedDevicesFragment.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.ui.devices 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.fragment.app.Fragment 8 | import androidx.recyclerview.widget.LinearLayoutManager 9 | import androidx.recyclerview.widget.RecyclerView 10 | import nl.rwslinkman.simdeviceble.AppModel 11 | import nl.rwslinkman.simdeviceble.R 12 | 13 | class SupportedDevicesFragment : Fragment() { 14 | 15 | override fun onCreateView( 16 | inflater: LayoutInflater, 17 | container: ViewGroup?, 18 | savedInstanceState: Bundle? 19 | ): View? { 20 | val root = inflater.inflate(R.layout.fragment_supported_devices, container, false) 21 | 22 | root.findViewById(R.id.supported_devices_list).apply { 23 | setHasFixedSize(true) 24 | layoutManager = LinearLayoutManager(context) 25 | adapter = SupportedDevicesAdapter(AppModel.supportedDevices) 26 | } 27 | 28 | return root 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/rwslinkman/simdeviceble/ui/home/HomeFragment.kt: -------------------------------------------------------------------------------- 1 | package nl.rwslinkman.simdeviceble.ui.home 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.os.Bundle 7 | import android.util.Log 8 | import android.view.LayoutInflater 9 | import android.view.View 10 | import android.view.ViewGroup 11 | import android.widget.* 12 | import android.widget.AdapterView.OnItemSelectedListener 13 | import androidx.appcompat.widget.SwitchCompat 14 | import androidx.fragment.app.Fragment 15 | import androidx.fragment.app.activityViewModels 16 | import androidx.lifecycle.Observer 17 | import nl.rwslinkman.simdeviceble.AppModel 18 | import nl.rwslinkman.simdeviceble.R 19 | import nl.rwslinkman.simdeviceble.device.model.Device 20 | 21 | 22 | class HomeFragment : Fragment() { 23 | 24 | private val appModel: AppModel by activityViewModels() 25 | private val selectorListener = object : OnItemSelectedListener { 26 | override fun onNothingSelected(p0: AdapterView<*>?) { 27 | Log.d(TAG, "onNothingSelected: ") 28 | } 29 | 30 | override fun onItemSelected( 31 | adapterView: AdapterView<*>?, 32 | view: View?, 33 | position: Int, 34 | id: Long 35 | ) { 36 | val selectedItem: String = adapterView?.getItemAtPosition(position) as String 37 | updateSelectedDevice(selectedItem) 38 | } 39 | } 40 | private lateinit var deviceSelectorAdapter: ArrayAdapter 41 | 42 | override fun onCreateView( 43 | inflater: LayoutInflater, 44 | container: ViewGroup?, 45 | savedInstanceState: Bundle? 46 | ): View? { 47 | val root = inflater.inflate(R.layout.fragment_home, container, false) 48 | 49 | //region Bluetooth 50 | val bleSupportedTextView: TextView = root.findViewById(R.id.ble_status_supported_value) 51 | val bleEnabledTextView: TextView = root.findViewById(R.id.ble_status_enabled_value) 52 | val bleAdvertisingSupportedTextView: TextView = 53 | root.findViewById(R.id.ble_status_advertising_value) 54 | val enableBluetoothBtn = root.findViewById