├── .gitignore ├── README.md ├── android └── serial-ble-rc │ └── SimpleBluetoothLeTerminal │ ├── .gitignore │ ├── LICENSE.txt │ ├── README.md │ ├── app │ ├── build.gradle │ ├── proguard-rules.pro │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ └── de │ │ │ └── kai_morich │ │ │ └── simple_bluetooth_le_terminal │ │ │ ├── Constants.java │ │ │ ├── MainActivity.java │ │ │ ├── SerialListener.java │ │ │ ├── SerialService.java │ │ │ ├── SerialSocket.java │ │ │ ├── TextUtil.java │ │ │ ├── fragments │ │ │ ├── DevicesFragment.java │ │ │ ├── JoystickFragment.java │ │ │ ├── PIDSettingsFragment.java │ │ │ ├── SerialEnabledFragment.java │ │ │ └── TerminalFragment.java │ │ │ └── views │ │ │ └── JoystickView.java │ │ └── res │ │ ├── drawable-hdpi │ │ ├── ic_clear_white_24dp.png │ │ └── ic_notification.png │ │ ├── drawable-mdpi │ │ ├── ic_clear_white_24dp.png │ │ └── ic_notification.png │ │ ├── drawable-xhdpi │ │ ├── ic_clear_white_24dp.png │ │ └── ic_notification.png │ │ ├── drawable-xxhdpi │ │ ├── ic_clear_white_24dp.png │ │ └── ic_notification.png │ │ ├── drawable-xxxhdpi │ │ ├── ic_clear_white_24dp.png │ │ └── ic_notification.png │ │ ├── drawable │ │ ├── ic_delete_white_24dp.xml │ │ └── ic_send_white_24dp.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── device_list_header.xml │ │ ├── device_list_item.xml │ │ ├── fragment_joystick.xml │ │ ├── fragment_pid_settings.xml │ │ └── fragment_terminal.xml │ │ ├── menu │ │ ├── menu_devices.xml │ │ ├── menu_joystick.xml │ │ └── menu_terminal.xml │ │ ├── mipmap-hdpi │ │ └── ic_launcher.png │ │ ├── mipmap-mdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxxhdpi │ │ └── ic_launcher.png │ │ └── values │ │ ├── arrays.xml │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── build.gradle │ ├── gradle.properties │ ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties │ ├── gradlew │ ├── gradlew.bat │ └── settings.gradle ├── docs └── stepper_frequency_estimation.py ├── drawings └── v3 │ ├── bottom.dxf │ ├── middle.dxf │ ├── side.dxf │ └── top-mount.dxf ├── esp32 ├── README.md ├── balancing-robot │ ├── .gitignore │ ├── .vscode │ │ ├── extensions.json │ │ └── settings.json │ ├── README.md │ ├── include │ │ └── README │ ├── lib │ │ └── README │ ├── platformio.ini │ ├── src │ │ ├── indicator │ │ │ ├── Indicator.cpp │ │ │ └── Indicator.h │ │ ├── main.cpp │ │ ├── pid │ │ │ ├── PID.cpp │ │ │ └── PID.h │ │ └── stepper │ │ │ ├── Stepper.cpp │ │ │ └── Stepper.h │ └── test │ │ └── README └── docs │ ├── test_bluetooth_classic.md │ ├── test_imu_sketch.md │ ├── test_rgb_led_sketch.md │ └── test_steppers_sketch.md ├── nano └── twbr-nano │ ├── .gitignore │ ├── .vscode │ └── extensions.json │ ├── include │ └── README │ ├── lib │ └── README │ ├── platformio.ini │ ├── src │ ├── main.cpp │ └── pid │ │ ├── PID.cpp │ │ └── PID.h │ └── test │ └── README └── r-pi ├── README.md ├── configure_wifi.sh ├── docs ├── install_deps.md └── rpi_shield.md ├── mpu_stream_client ├── README.md ├── mpu_client.html ├── requirements.txt └── src │ └── mpu_stream_client.py ├── requirements.txt ├── run.sh └── src ├── __init__.py ├── balance.py ├── control_stepper_with_mpu.py ├── motor ├── __init__.py └── stepper.py ├── mpu_test.py ├── recv_mpu_data.py ├── roll_pitch_test.py ├── sensor ├── __init__.py └── mpu.py ├── stepper_test.py ├── stream_mpu_data.py └── util ├── __init__.py ├── pid.py └── timed_task.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | __pycache__/ 3 | 4 | .DS_Store 5 | sftp-config.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Self-balancing robot 2 | 3 | BLE-controlled self-balancing robot 4 | 5 | - [Android BLE Joystick](/android/serial-ble-rc) 6 | - [Arduino Code](/nano/twbr-nano) 7 | 8 | ## BOM 9 | 10 | - Keywish BLE Nano x1 11 | - A4988 Stepper Driver x2 12 | - Stepper motor Nema17 x2 13 | - MPU6050 x1 14 | 15 | ## References 16 | 17 | - [Hackaday](https://hackaday.io/project/180126-self-balancing-robot-for-humans/) 18 | - [Arduino Project](https://create.arduino.cc/projecthub/zjor/self-balancing-robot-with-arduino-nano-and-steppers-9bf019) 19 | - [![Youtube](https://img.youtube.com/vi/_VPTOirKccM/0.jpg)](https://youtu.be/_VPTOirKccM) 20 | - [Habr](https://habr.com/ru/post/575662/) -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | /.gradle/ 3 | /.idea/ 4 | /local.properties 5 | /app/build/ 6 | /build/ 7 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Kai Morich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/README.md: -------------------------------------------------------------------------------- 1 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/3f9ba45b5c5449179150010659311f57)](https://www.codacy.com/manual/kai-morich/SimpleBluetoothLeTerminal?utm_source=github.com&utm_medium=referral&utm_content=kai-morich/SimpleBluetoothLeTerminal&utm_campaign=Badge_Grade) 2 | 3 | # SimpleBluetoothLeTerminal 4 | 5 | This Android app provides a line-oriented terminal / console for Bluetooth LE (4.x) devices implementing a custom serial profile 6 | 7 | For an overview on Android BLE communication see 8 | [Android Bluetooth LE Overview](https://developer.android.com/guide/topics/connectivity/bluetooth/ble-overview). 9 | 10 | In contrast to classic Bluetooth, there is no predifined serial profile for Bluetooth LE, 11 | so each vendor uses GATT services with different service and characteristic UUIDs. 12 | 13 | This app includes UUIDs for widely used serial profiles: 14 | - Nordic Semiconductor nRF51822 15 | - Texas Instruments CC254x 16 | - Microchip RN4870/1 17 | - Telit Bluemod 18 | 19 | ## Motivation 20 | 21 | I got various requests asking for help with Android development or source code for my 22 | [Serial Bluetooth Terminal](https://play.google.com/store/apps/details?id=de.kai_morich.serial_bluetooth_terminal) app. 23 | Here you find a simplified version of my app. 24 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | android { 4 | compileSdkVersion 29 5 | defaultConfig { 6 | targetSdkVersion 29 7 | minSdkVersion 18 8 | vectorDrawables.useSupportLibrary true 9 | 10 | applicationId "de.kai_morich.simple_bluetooth_le_terminal" 11 | versionCode 1 12 | versionName "1.0" 13 | } 14 | compileOptions { 15 | sourceCompatibility JavaVersion.VERSION_1_8 16 | targetCompatibility JavaVersion.VERSION_1_8 17 | } 18 | buildTypes { 19 | release { 20 | minifyEnabled false 21 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 22 | } 23 | } 24 | } 25 | 26 | dependencies { 27 | implementation 'androidx.appcompat:appcompat:1.2.0' 28 | implementation 'com.google.android.material:material:1.3.0' 29 | implementation 'androidx.preference:preference:1.1.1' 30 | implementation 'io.reactivex.rxjava3:rxandroid:3.0.0' 31 | implementation 'io.reactivex.rxjava3:rxjava:3.0.0' 32 | } 33 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/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 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/java/de/kai_morich/simple_bluetooth_le_terminal/Constants.java: -------------------------------------------------------------------------------- 1 | package de.kai_morich.simple_bluetooth_le_terminal; 2 | 3 | class Constants { 4 | 5 | // values have to be globally unique 6 | static final String INTENT_ACTION_DISCONNECT = BuildConfig.APPLICATION_ID + ".Disconnect"; 7 | static final String NOTIFICATION_CHANNEL = BuildConfig.APPLICATION_ID + ".Channel"; 8 | static final String INTENT_CLASS_MAIN_ACTIVITY = BuildConfig.APPLICATION_ID + ".MainActivity"; 9 | 10 | // values have to be unique within each app 11 | static final int NOTIFY_MANAGER_START_FOREGROUND_SERVICE = 1001; 12 | 13 | private Constants() {} 14 | } 15 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/java/de/kai_morich/simple_bluetooth_le_terminal/MainActivity.java: -------------------------------------------------------------------------------- 1 | package de.kai_morich.simple_bluetooth_le_terminal; 2 | 3 | import android.os.Bundle; 4 | 5 | import androidx.appcompat.app.AppCompatActivity; 6 | import androidx.appcompat.widget.Toolbar; 7 | import androidx.fragment.app.FragmentManager; 8 | 9 | import de.kai_morich.simple_bluetooth_le_terminal.fragments.DevicesFragment; 10 | 11 | public class MainActivity extends AppCompatActivity implements FragmentManager.OnBackStackChangedListener { 12 | 13 | @Override 14 | protected void onCreate(Bundle savedInstanceState) { 15 | super.onCreate(savedInstanceState); 16 | setContentView(R.layout.activity_main); 17 | Toolbar toolbar = findViewById(R.id.toolbar); 18 | setSupportActionBar(toolbar); 19 | getSupportFragmentManager().addOnBackStackChangedListener(this); 20 | if (savedInstanceState == null) 21 | getSupportFragmentManager().beginTransaction().add(R.id.fragment, new DevicesFragment(), "devices").commit(); 22 | else 23 | onBackStackChanged(); 24 | } 25 | 26 | @Override 27 | public void onBackStackChanged() { 28 | getSupportActionBar().setDisplayHomeAsUpEnabled(getSupportFragmentManager().getBackStackEntryCount()>0); 29 | } 30 | 31 | @Override 32 | public boolean onSupportNavigateUp() { 33 | onBackPressed(); 34 | return true; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/java/de/kai_morich/simple_bluetooth_le_terminal/SerialListener.java: -------------------------------------------------------------------------------- 1 | package de.kai_morich.simple_bluetooth_le_terminal; 2 | 3 | public interface SerialListener { 4 | void onSerialConnect(); 5 | 6 | void onSerialConnectError(Exception e); 7 | 8 | void onSerialRead(byte[] data); 9 | 10 | void onSerialIoError(Exception e); 11 | } 12 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/java/de/kai_morich/simple_bluetooth_le_terminal/SerialService.java: -------------------------------------------------------------------------------- 1 | package de.kai_morich.simple_bluetooth_le_terminal; 2 | 3 | import android.app.Notification; 4 | import android.app.NotificationChannel; 5 | import android.app.NotificationManager; 6 | import android.app.PendingIntent; 7 | import android.app.Service; 8 | import android.content.Context; 9 | import android.content.Intent; 10 | import android.os.Binder; 11 | import android.os.Build; 12 | import android.os.Handler; 13 | import android.os.IBinder; 14 | import android.os.Looper; 15 | 16 | import androidx.annotation.Nullable; 17 | import androidx.core.app.NotificationCompat; 18 | 19 | import java.io.IOException; 20 | import java.util.LinkedList; 21 | import java.util.Queue; 22 | 23 | /** 24 | * create notification and queue serial data while activity is not in the foreground 25 | * use listener chain: SerialSocket -> SerialService -> UI fragment 26 | */ 27 | public class SerialService extends Service implements SerialListener { 28 | 29 | public class SerialBinder extends Binder { 30 | public SerialService getService() { return SerialService.this; } 31 | } 32 | 33 | private enum QueueType {Connect, ConnectError, Read, IoError} 34 | 35 | private static class QueueItem { 36 | QueueType type; 37 | byte[] data; 38 | Exception e; 39 | 40 | QueueItem(QueueType type, byte[] data, Exception e) { this.type=type; this.data=data; this.e=e; } 41 | } 42 | 43 | private final Handler mainLooper; 44 | private final IBinder binder; 45 | private final Queue queue1, queue2; 46 | 47 | private SerialSocket socket; 48 | private SerialListener listener; 49 | private boolean connected; 50 | 51 | /** 52 | * Lifecylce 53 | */ 54 | public SerialService() { 55 | mainLooper = new Handler(Looper.getMainLooper()); 56 | binder = new SerialBinder(); 57 | queue1 = new LinkedList<>(); 58 | queue2 = new LinkedList<>(); 59 | } 60 | 61 | @Override 62 | public void onDestroy() { 63 | cancelNotification(); 64 | disconnect(); 65 | super.onDestroy(); 66 | } 67 | 68 | @Nullable 69 | @Override 70 | public IBinder onBind(Intent intent) { 71 | return binder; 72 | } 73 | 74 | /** 75 | * Api 76 | */ 77 | public void connect(SerialSocket socket) throws IOException { 78 | socket.connect(this); 79 | this.socket = socket; 80 | connected = true; 81 | } 82 | 83 | public void disconnect() { 84 | connected = false; // ignore data,errors while disconnecting 85 | cancelNotification(); 86 | if(socket != null) { 87 | socket.disconnect(); 88 | socket = null; 89 | } 90 | } 91 | 92 | public void write(byte[] data) throws IOException { 93 | if(!connected) 94 | throw new IOException("not connected"); 95 | socket.write(data); 96 | } 97 | 98 | public void attach(SerialListener listener) { 99 | if(Looper.getMainLooper().getThread() != Thread.currentThread()) 100 | throw new IllegalArgumentException("not in main thread"); 101 | cancelNotification(); 102 | // use synchronized() to prevent new items in queue2 103 | // new items will not be added to queue1 because mainLooper.post and attach() run in main thread 104 | synchronized (this) { 105 | this.listener = listener; 106 | } 107 | for(QueueItem item : queue1) { 108 | switch(item.type) { 109 | case Connect: listener.onSerialConnect (); break; 110 | case ConnectError: listener.onSerialConnectError (item.e); break; 111 | case Read: listener.onSerialRead (item.data); break; 112 | case IoError: listener.onSerialIoError (item.e); break; 113 | } 114 | } 115 | for(QueueItem item : queue2) { 116 | switch(item.type) { 117 | case Connect: listener.onSerialConnect (); break; 118 | case ConnectError: listener.onSerialConnectError (item.e); break; 119 | case Read: listener.onSerialRead (item.data); break; 120 | case IoError: listener.onSerialIoError (item.e); break; 121 | } 122 | } 123 | queue1.clear(); 124 | queue2.clear(); 125 | } 126 | 127 | public void detach() { 128 | if(connected) 129 | createNotification(); 130 | // items already in event queue (posted before detach() to mainLooper) will end up in queue1 131 | // items occurring later, will be moved directly to queue2 132 | // detach() and mainLooper.post run in the main thread, so all items are caught 133 | listener = null; 134 | } 135 | 136 | private void createNotification() { 137 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 138 | NotificationChannel nc = new NotificationChannel(Constants.NOTIFICATION_CHANNEL, "Background service", NotificationManager.IMPORTANCE_LOW); 139 | nc.setShowBadge(false); 140 | NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); 141 | nm.createNotificationChannel(nc); 142 | } 143 | Intent disconnectIntent = new Intent() 144 | .setAction(Constants.INTENT_ACTION_DISCONNECT); 145 | Intent restartIntent = new Intent() 146 | .setClassName(this, Constants.INTENT_CLASS_MAIN_ACTIVITY) 147 | .setAction(Intent.ACTION_MAIN) 148 | .addCategory(Intent.CATEGORY_LAUNCHER); 149 | PendingIntent disconnectPendingIntent = PendingIntent.getBroadcast(this, 1, disconnectIntent, PendingIntent.FLAG_UPDATE_CURRENT); 150 | PendingIntent restartPendingIntent = PendingIntent.getActivity(this, 1, restartIntent, PendingIntent.FLAG_UPDATE_CURRENT); 151 | NotificationCompat.Builder builder = new NotificationCompat.Builder(this, Constants.NOTIFICATION_CHANNEL) 152 | .setSmallIcon(R.drawable.ic_notification) 153 | .setColor(getResources().getColor(R.color.colorPrimary)) 154 | .setContentTitle(getResources().getString(R.string.app_name)) 155 | .setContentText(socket != null ? "Connected to "+socket.getName() : "Background Service") 156 | .setContentIntent(restartPendingIntent) 157 | .setOngoing(true) 158 | .addAction(new NotificationCompat.Action(R.drawable.ic_clear_white_24dp, "Disconnect", disconnectPendingIntent)); 159 | // @drawable/ic_notification created with Android Studio -> New -> Image Asset using @color/colorPrimaryDark as background color 160 | // Android < API 21 does not support vectorDrawables in notifications, so both drawables used here, are created as .png instead of .xml 161 | Notification notification = builder.build(); 162 | startForeground(Constants.NOTIFY_MANAGER_START_FOREGROUND_SERVICE, notification); 163 | } 164 | 165 | private void cancelNotification() { 166 | stopForeground(true); 167 | } 168 | 169 | /** 170 | * SerialListener 171 | */ 172 | public void onSerialConnect() { 173 | if(connected) { 174 | synchronized (this) { 175 | if (listener != null) { 176 | mainLooper.post(() -> { 177 | if (listener != null) { 178 | listener.onSerialConnect(); 179 | } else { 180 | queue1.add(new QueueItem(QueueType.Connect, null, null)); 181 | } 182 | }); 183 | } else { 184 | queue2.add(new QueueItem(QueueType.Connect, null, null)); 185 | } 186 | } 187 | } 188 | } 189 | 190 | public void onSerialConnectError(Exception e) { 191 | if(connected) { 192 | synchronized (this) { 193 | if (listener != null) { 194 | mainLooper.post(() -> { 195 | if (listener != null) { 196 | listener.onSerialConnectError(e); 197 | } else { 198 | queue1.add(new QueueItem(QueueType.ConnectError, null, e)); 199 | cancelNotification(); 200 | disconnect(); 201 | } 202 | }); 203 | } else { 204 | queue2.add(new QueueItem(QueueType.ConnectError, null, e)); 205 | cancelNotification(); 206 | disconnect(); 207 | } 208 | } 209 | } 210 | } 211 | 212 | public void onSerialRead(byte[] data) { 213 | if(connected) { 214 | synchronized (this) { 215 | if (listener != null) { 216 | mainLooper.post(() -> { 217 | if (listener != null) { 218 | listener.onSerialRead(data); 219 | } else { 220 | queue1.add(new QueueItem(QueueType.Read, data, null)); 221 | } 222 | }); 223 | } else { 224 | queue2.add(new QueueItem(QueueType.Read, data, null)); 225 | } 226 | } 227 | } 228 | } 229 | 230 | public void onSerialIoError(Exception e) { 231 | if(connected) { 232 | synchronized (this) { 233 | if (listener != null) { 234 | mainLooper.post(() -> { 235 | if (listener != null) { 236 | listener.onSerialIoError(e); 237 | } else { 238 | queue1.add(new QueueItem(QueueType.IoError, null, e)); 239 | cancelNotification(); 240 | disconnect(); 241 | } 242 | }); 243 | } else { 244 | queue2.add(new QueueItem(QueueType.IoError, null, e)); 245 | cancelNotification(); 246 | disconnect(); 247 | } 248 | } 249 | } 250 | } 251 | 252 | } 253 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/java/de/kai_morich/simple_bluetooth_le_terminal/SerialSocket.java: -------------------------------------------------------------------------------- 1 | package de.kai_morich.simple_bluetooth_le_terminal; 2 | 3 | import android.app.Activity; 4 | import android.bluetooth.BluetoothDevice; 5 | import android.bluetooth.BluetoothGatt; 6 | import android.bluetooth.BluetoothGattCallback; 7 | import android.bluetooth.BluetoothGattCharacteristic; 8 | import android.bluetooth.BluetoothGattDescriptor; 9 | import android.bluetooth.BluetoothGattService; 10 | import android.bluetooth.BluetoothProfile; 11 | import android.content.BroadcastReceiver; 12 | import android.content.Context; 13 | import android.content.Intent; 14 | import android.content.IntentFilter; 15 | import android.os.Build; 16 | import android.util.Log; 17 | 18 | import java.io.IOException; 19 | import java.security.InvalidParameterException; 20 | import java.util.ArrayList; 21 | import java.util.Arrays; 22 | import java.util.UUID; 23 | 24 | /** 25 | * wrap BLE communication into socket like class 26 | * - connect, disconnect and write as methods, 27 | * - read + status is returned by SerialListener 28 | */ 29 | public class SerialSocket extends BluetoothGattCallback { 30 | 31 | /** 32 | * delegate device specific behaviour to inner class 33 | */ 34 | private static class DeviceDelegate { 35 | boolean connectCharacteristics(BluetoothGattService s) { return true; } 36 | // following methods only overwritten for Telit devices 37 | void onDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor d, int status) { /*nop*/ } 38 | void onCharacteristicChanged(BluetoothGatt g, BluetoothGattCharacteristic c) {/*nop*/ } 39 | void onCharacteristicWrite(BluetoothGatt g, BluetoothGattCharacteristic c, int status) { /*nop*/ } 40 | boolean canWrite() { return true; } 41 | void disconnect() {/*nop*/ } 42 | } 43 | 44 | private static final UUID BLUETOOTH_LE_CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); 45 | private static final UUID BLUETOOTH_LE_CC254X_SERVICE = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb"); 46 | private static final UUID BLUETOOTH_LE_CC254X_CHAR_RW = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb"); 47 | private static final UUID BLUETOOTH_LE_NRF_SERVICE = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9e"); 48 | private static final UUID BLUETOOTH_LE_NRF_CHAR_RW2 = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9e"); // read on microbit, write on adafruit 49 | private static final UUID BLUETOOTH_LE_NRF_CHAR_RW3 = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9e"); 50 | private static final UUID BLUETOOTH_LE_RN4870_SERVICE = UUID.fromString("49535343-FE7D-4AE5-8FA9-9FAFD205E455"); 51 | private static final UUID BLUETOOTH_LE_RN4870_CHAR_RW = UUID.fromString("49535343-1E4D-4BD9-BA61-23C647249616"); 52 | 53 | // https://play.google.com/store/apps/details?id=com.telit.tiosample 54 | // https://www.telit.com/wp-content/uploads/2017/09/TIO_Implementation_Guide_r6.pdf 55 | private static final UUID BLUETOOTH_LE_TIO_SERVICE = UUID.fromString("0000FEFB-0000-1000-8000-00805F9B34FB"); 56 | private static final UUID BLUETOOTH_LE_TIO_CHAR_TX = UUID.fromString("00000001-0000-1000-8000-008025000000"); // WNR 57 | private static final UUID BLUETOOTH_LE_TIO_CHAR_RX = UUID.fromString("00000002-0000-1000-8000-008025000000"); // N 58 | private static final UUID BLUETOOTH_LE_TIO_CHAR_TX_CREDITS = UUID.fromString("00000003-0000-1000-8000-008025000000"); // W 59 | private static final UUID BLUETOOTH_LE_TIO_CHAR_RX_CREDITS = UUID.fromString("00000004-0000-1000-8000-008025000000"); // I 60 | 61 | private static final int MAX_MTU = 512; // BLE standard does not limit, some BLE 4.2 devices support 251, various source say that Android has max 512 62 | private static final int DEFAULT_MTU = 23; 63 | private static final String TAG = "SerialSocket"; 64 | 65 | private final ArrayList writeBuffer; 66 | private final IntentFilter pairingIntentFilter; 67 | private final BroadcastReceiver pairingBroadcastReceiver; 68 | private final BroadcastReceiver disconnectBroadcastReceiver; 69 | 70 | private final Context context; 71 | private SerialListener listener; 72 | private DeviceDelegate delegate; 73 | private BluetoothDevice device; 74 | private BluetoothGatt gatt; 75 | private BluetoothGattCharacteristic readCharacteristic, writeCharacteristic; 76 | 77 | private boolean writePending; 78 | private boolean canceled; 79 | private boolean connected; 80 | private int payloadSize = DEFAULT_MTU-3; 81 | 82 | public SerialSocket(Context context, BluetoothDevice device) { 83 | if(context instanceof Activity) 84 | throw new InvalidParameterException("expected non UI context"); 85 | this.context = context; 86 | this.device = device; 87 | writeBuffer = new ArrayList<>(); 88 | pairingIntentFilter = new IntentFilter(); 89 | pairingIntentFilter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED); 90 | pairingIntentFilter.addAction(BluetoothDevice.ACTION_PAIRING_REQUEST); 91 | pairingBroadcastReceiver = new BroadcastReceiver() { 92 | @Override 93 | public void onReceive(Context context, Intent intent) { 94 | onPairingBroadcastReceive(context, intent); 95 | } 96 | }; 97 | disconnectBroadcastReceiver = new BroadcastReceiver() { 98 | @Override 99 | public void onReceive(Context context, Intent intent) { 100 | if(listener != null) 101 | listener.onSerialIoError(new IOException("background disconnect")); 102 | disconnect(); // disconnect now, else would be queued until UI re-attached 103 | } 104 | }; 105 | } 106 | 107 | String getName() { 108 | return device.getName() != null ? device.getName() : device.getAddress(); 109 | } 110 | 111 | void disconnect() { 112 | Log.d(TAG, "disconnect"); 113 | listener = null; // ignore remaining data and errors 114 | device = null; 115 | canceled = true; 116 | synchronized (writeBuffer) { 117 | writePending = false; 118 | writeBuffer.clear(); 119 | } 120 | readCharacteristic = null; 121 | writeCharacteristic = null; 122 | if(delegate != null) 123 | delegate.disconnect(); 124 | if (gatt != null) { 125 | Log.d(TAG, "gatt.disconnect"); 126 | gatt.disconnect(); 127 | Log.d(TAG, "gatt.close"); 128 | try { 129 | gatt.close(); 130 | } catch (Exception ignored) {} 131 | gatt = null; 132 | connected = false; 133 | } 134 | try { 135 | context.unregisterReceiver(pairingBroadcastReceiver); 136 | } catch (Exception ignored) { 137 | } 138 | try { 139 | context.unregisterReceiver(disconnectBroadcastReceiver); 140 | } catch (Exception ignored) { 141 | } 142 | } 143 | 144 | /** 145 | * connect-success and most connect-errors are returned asynchronously to listener 146 | */ 147 | void connect(SerialListener listener) throws IOException { 148 | if(connected || gatt != null) 149 | throw new IOException("already connected"); 150 | canceled = false; 151 | this.listener = listener; 152 | context.registerReceiver(disconnectBroadcastReceiver, new IntentFilter(Constants.INTENT_ACTION_DISCONNECT)); 153 | Log.d(TAG, "connect "+device); 154 | context.registerReceiver(pairingBroadcastReceiver, pairingIntentFilter); 155 | if (Build.VERSION.SDK_INT < 23) { 156 | Log.d(TAG, "connectGatt"); 157 | gatt = device.connectGatt(context, false, this); 158 | } else { 159 | Log.d(TAG, "connectGatt,LE"); 160 | gatt = device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE); 161 | } 162 | if (gatt == null) 163 | throw new IOException("connectGatt failed"); 164 | // continues asynchronously in onPairingBroadcastReceive() and onConnectionStateChange() 165 | } 166 | 167 | private void onPairingBroadcastReceive(Context context, Intent intent) { 168 | // for ARM Mbed, Microbit, ... use pairing from Android bluetooth settings 169 | // for HM10-clone, ... pairing is initiated here 170 | BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); 171 | if(device==null || !device.equals(this.device)) 172 | return; 173 | switch (intent.getAction()) { 174 | case BluetoothDevice.ACTION_PAIRING_REQUEST: 175 | final int pairingVariant = intent.getIntExtra(BluetoothDevice.EXTRA_PAIRING_VARIANT, -1); 176 | Log.d(TAG, "pairing request " + pairingVariant); 177 | onSerialConnectError(new IOException(context.getString(R.string.pairing_request))); 178 | // pairing dialog brings app to background (onPause), but it is still partly visible (no onStop), so there is no automatic disconnect() 179 | break; 180 | case BluetoothDevice.ACTION_BOND_STATE_CHANGED: 181 | final int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1); 182 | final int previousBondState = intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, -1); 183 | Log.d(TAG, "bond state " + previousBondState + "->" + bondState); 184 | break; 185 | default: 186 | Log.d(TAG, "unknown broadcast " + intent.getAction()); 187 | break; 188 | } 189 | } 190 | 191 | @Override 192 | public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { 193 | // status directly taken from gat_api.h, e.g. 133=0x85=GATT_ERROR ~= timeout 194 | if (newState == BluetoothProfile.STATE_CONNECTED) { 195 | Log.d(TAG,"connect status "+status+", discoverServices"); 196 | if (!gatt.discoverServices()) 197 | onSerialConnectError(new IOException("discoverServices failed")); 198 | } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { 199 | if (connected) 200 | onSerialIoError (new IOException("gatt status " + status)); 201 | else 202 | onSerialConnectError(new IOException("gatt status " + status)); 203 | } else { 204 | Log.d(TAG, "unknown connect state "+newState+" "+status); 205 | } 206 | // continues asynchronously in onServicesDiscovered() 207 | } 208 | 209 | @Override 210 | public void onServicesDiscovered(BluetoothGatt gatt, int status) { 211 | Log.d(TAG, "servicesDiscovered, status " + status); 212 | if (canceled) 213 | return; 214 | connectCharacteristics1(gatt); 215 | } 216 | 217 | private void connectCharacteristics1(BluetoothGatt gatt) { 218 | boolean sync = true; 219 | writePending = false; 220 | for (BluetoothGattService gattService : gatt.getServices()) { 221 | if (gattService.getUuid().equals(BLUETOOTH_LE_CC254X_SERVICE)) 222 | delegate = new Cc245XDelegate(); 223 | if (gattService.getUuid().equals(BLUETOOTH_LE_RN4870_SERVICE)) 224 | delegate = new Rn4870Delegate(); 225 | if (gattService.getUuid().equals(BLUETOOTH_LE_NRF_SERVICE)) 226 | delegate = new NrfDelegate(); 227 | if (gattService.getUuid().equals(BLUETOOTH_LE_TIO_SERVICE)) 228 | delegate = new TelitDelegate(); 229 | 230 | if(delegate != null) { 231 | sync = delegate.connectCharacteristics(gattService); 232 | break; 233 | } 234 | } 235 | if(canceled) 236 | return; 237 | if(delegate==null || readCharacteristic==null || writeCharacteristic==null) { 238 | for (BluetoothGattService gattService : gatt.getServices()) { 239 | Log.d(TAG, "service "+gattService.getUuid()); 240 | for(BluetoothGattCharacteristic characteristic : gattService.getCharacteristics()) 241 | Log.d(TAG, "characteristic "+characteristic.getUuid()); 242 | } 243 | onSerialConnectError(new IOException("no serial profile found")); 244 | return; 245 | } 246 | if(sync) 247 | connectCharacteristics2(gatt); 248 | } 249 | 250 | private void connectCharacteristics2(BluetoothGatt gatt) { 251 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 252 | Log.d(TAG, "request max MTU"); 253 | if (!gatt.requestMtu(MAX_MTU)) 254 | onSerialConnectError(new IOException("request MTU failed")); 255 | // continues asynchronously in onMtuChanged 256 | } else { 257 | connectCharacteristics3(gatt); 258 | } 259 | } 260 | 261 | @Override 262 | public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { 263 | Log.d(TAG,"mtu size "+mtu+", status="+status); 264 | if(status == BluetoothGatt.GATT_SUCCESS) { 265 | payloadSize = mtu - 3; 266 | Log.d(TAG, "payload size "+payloadSize); 267 | } 268 | connectCharacteristics3(gatt); 269 | } 270 | 271 | private void connectCharacteristics3(BluetoothGatt gatt) { 272 | int writeProperties = writeCharacteristic.getProperties(); 273 | if((writeProperties & (BluetoothGattCharacteristic.PROPERTY_WRITE + // Microbit,HM10-clone have WRITE 274 | BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE)) ==0) { // HM10,TI uart,Telit have only WRITE_NO_RESPONSE 275 | onSerialConnectError(new IOException("write characteristic not writable")); 276 | return; 277 | } 278 | if(!gatt.setCharacteristicNotification(readCharacteristic,true)) { 279 | onSerialConnectError(new IOException("no notification for read characteristic")); 280 | return; 281 | } 282 | BluetoothGattDescriptor readDescriptor = readCharacteristic.getDescriptor(BLUETOOTH_LE_CCCD); 283 | if(readDescriptor == null) { 284 | onSerialConnectError(new IOException("no CCCD descriptor for read characteristic")); 285 | return; 286 | } 287 | int readProperties = readCharacteristic.getProperties(); 288 | if((readProperties & BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) { 289 | Log.d(TAG, "enable read indication"); 290 | readDescriptor.setValue(BluetoothGattDescriptor.ENABLE_INDICATION_VALUE); 291 | }else if((readProperties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { 292 | Log.d(TAG, "enable read notification"); 293 | readDescriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); 294 | } else { 295 | onSerialConnectError(new IOException("no indication/notification for read characteristic ("+readProperties+")")); 296 | return; 297 | } 298 | Log.d(TAG,"writing read characteristic descriptor"); 299 | if(!gatt.writeDescriptor(readDescriptor)) { 300 | onSerialConnectError(new IOException("read characteristic CCCD descriptor not writable")); 301 | } 302 | // continues asynchronously in onDescriptorWrite() 303 | } 304 | 305 | @Override 306 | public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { 307 | delegate.onDescriptorWrite(gatt, descriptor, status); 308 | if(canceled) 309 | return; 310 | if(descriptor.getCharacteristic() == readCharacteristic) { 311 | Log.d(TAG,"writing read characteristic descriptor finished, status="+status); 312 | if (status != BluetoothGatt.GATT_SUCCESS) { 313 | onSerialConnectError(new IOException("write descriptor failed")); 314 | } else { 315 | // onCharacteristicChanged with incoming data can happen after writeDescriptor(ENABLE_INDICATION/NOTIFICATION) 316 | // before confirmed by this method, so receive data can be shown before device is shown as 'Connected'. 317 | onSerialConnect(); 318 | connected = true; 319 | Log.d(TAG, "connected"); 320 | } 321 | } 322 | } 323 | 324 | /* 325 | * read 326 | */ 327 | @Override 328 | public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { 329 | if(canceled) 330 | return; 331 | delegate.onCharacteristicChanged(gatt, characteristic); 332 | if(canceled) 333 | return; 334 | if(characteristic == readCharacteristic) { // NOPMD - test object identity 335 | byte[] data = readCharacteristic.getValue(); 336 | onSerialRead(data); 337 | Log.d(TAG,"read, len="+data.length); 338 | } 339 | } 340 | 341 | /* 342 | * write 343 | */ 344 | void write(byte[] data) throws IOException { 345 | if(canceled || !connected || writeCharacteristic == null) 346 | throw new IOException("not connected"); 347 | byte[] data0; 348 | synchronized (writeBuffer) { 349 | if(data.length <= payloadSize) { 350 | data0 = data; 351 | } else { 352 | data0 = Arrays.copyOfRange(data, 0, payloadSize); 353 | } 354 | if(!writePending && writeBuffer.isEmpty() && delegate.canWrite()) { 355 | writePending = true; 356 | } else { 357 | writeBuffer.add(data0); 358 | Log.d(TAG,"write queued, len="+data0.length); 359 | data0 = null; 360 | } 361 | if(data.length > payloadSize) { 362 | for(int i=1; i<(data.length+payloadSize-1)/payloadSize; i++) { 363 | int from = i*payloadSize; 364 | int to = Math.min(from+payloadSize, data.length); 365 | writeBuffer.add(Arrays.copyOfRange(data, from, to)); 366 | Log.d(TAG,"write queued, len="+(to-from)); 367 | } 368 | } 369 | } 370 | if(data0 != null) { 371 | writeCharacteristic.setValue(data0); 372 | if (!gatt.writeCharacteristic(writeCharacteristic)) { 373 | onSerialIoError(new IOException("write failed")); 374 | } else { 375 | Log.d(TAG,"write started, len="+data0.length); 376 | } 377 | } 378 | // continues asynchronously in onCharacteristicWrite() 379 | } 380 | 381 | @Override 382 | public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { 383 | if(canceled || !connected || writeCharacteristic == null) 384 | return; 385 | if(status != BluetoothGatt.GATT_SUCCESS) { 386 | onSerialIoError(new IOException("write failed")); 387 | return; 388 | } 389 | delegate.onCharacteristicWrite(gatt, characteristic, status); 390 | if(canceled) 391 | return; 392 | if(characteristic == writeCharacteristic) { // NOPMD - test object identity 393 | Log.d(TAG,"write finished, status="+status); 394 | writeNext(); 395 | } 396 | } 397 | 398 | private void writeNext() { 399 | final byte[] data; 400 | synchronized (writeBuffer) { 401 | if (!writeBuffer.isEmpty() && delegate.canWrite()) { 402 | writePending = true; 403 | data = writeBuffer.remove(0); 404 | } else { 405 | writePending = false; 406 | data = null; 407 | } 408 | } 409 | if(data != null) { 410 | writeCharacteristic.setValue(data); 411 | if (!gatt.writeCharacteristic(writeCharacteristic)) { 412 | onSerialIoError(new IOException("write failed")); 413 | } else { 414 | Log.d(TAG,"write started, len="+data.length); 415 | } 416 | } 417 | } 418 | 419 | /** 420 | * SerialListener 421 | */ 422 | private void onSerialConnect() { 423 | if (listener != null) 424 | listener.onSerialConnect(); 425 | } 426 | 427 | private void onSerialConnectError(Exception e) { 428 | canceled = true; 429 | if (listener != null) 430 | listener.onSerialConnectError(e); 431 | } 432 | 433 | private void onSerialRead(byte[] data) { 434 | if (listener != null) 435 | listener.onSerialRead(data); 436 | } 437 | 438 | private void onSerialIoError(Exception e) { 439 | writePending = false; 440 | canceled = true; 441 | if (listener != null) 442 | listener.onSerialIoError(e); 443 | } 444 | 445 | /** 446 | * device delegates 447 | */ 448 | 449 | private class Cc245XDelegate extends DeviceDelegate { 450 | @Override 451 | boolean connectCharacteristics(BluetoothGattService gattService) { 452 | Log.d(TAG, "service cc254x uart"); 453 | readCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_CC254X_CHAR_RW); 454 | writeCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_CC254X_CHAR_RW); 455 | return true; 456 | } 457 | } 458 | 459 | private class Rn4870Delegate extends DeviceDelegate { 460 | @Override 461 | boolean connectCharacteristics(BluetoothGattService gattService) { 462 | Log.d(TAG, "service rn4870 uart"); 463 | readCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_RN4870_CHAR_RW); 464 | writeCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_RN4870_CHAR_RW); 465 | return true; 466 | } 467 | } 468 | 469 | private class NrfDelegate extends DeviceDelegate { 470 | @Override 471 | boolean connectCharacteristics(BluetoothGattService gattService) { 472 | Log.d(TAG, "service nrf uart"); 473 | BluetoothGattCharacteristic rw2 = gattService.getCharacteristic(BLUETOOTH_LE_NRF_CHAR_RW2); 474 | BluetoothGattCharacteristic rw3 = gattService.getCharacteristic(BLUETOOTH_LE_NRF_CHAR_RW3); 475 | if (rw2 != null && rw3 != null) { 476 | int rw2prop = rw2.getProperties(); 477 | int rw3prop = rw3.getProperties(); 478 | boolean rw2write = (rw2prop & BluetoothGattCharacteristic.PROPERTY_WRITE) != 0; 479 | boolean rw3write = (rw3prop & BluetoothGattCharacteristic.PROPERTY_WRITE) != 0; 480 | Log.d(TAG, "characteristic properties " + rw2prop + "/" + rw3prop); 481 | if (rw2write && rw3write) { 482 | onSerialConnectError(new IOException("multiple write characteristics (" + rw2prop + "/" + rw3prop + ")")); 483 | } else if (rw2write) { 484 | writeCharacteristic = rw2; 485 | readCharacteristic = rw3; 486 | } else if (rw3write) { 487 | writeCharacteristic = rw3; 488 | readCharacteristic = rw2; 489 | } else { 490 | onSerialConnectError(new IOException("no write characteristic (" + rw2prop + "/" + rw3prop + ")")); 491 | } 492 | } 493 | return true; 494 | } 495 | } 496 | 497 | private class TelitDelegate extends DeviceDelegate { 498 | private BluetoothGattCharacteristic readCreditsCharacteristic, writeCreditsCharacteristic; 499 | private int readCredits, writeCredits; 500 | 501 | @Override 502 | boolean connectCharacteristics(BluetoothGattService gattService) { 503 | Log.d(TAG, "service telit tio 2.0"); 504 | readCredits = 0; 505 | writeCredits = 0; 506 | readCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_TIO_CHAR_RX); 507 | writeCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_TIO_CHAR_TX); 508 | readCreditsCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_TIO_CHAR_RX_CREDITS); 509 | writeCreditsCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_TIO_CHAR_TX_CREDITS); 510 | if (readCharacteristic == null) { 511 | onSerialConnectError(new IOException("read characteristic not found")); 512 | return false; 513 | } 514 | if (writeCharacteristic == null) { 515 | onSerialConnectError(new IOException("write characteristic not found")); 516 | return false; 517 | } 518 | if (readCreditsCharacteristic == null) { 519 | onSerialConnectError(new IOException("read credits characteristic not found")); 520 | return false; 521 | } 522 | if (writeCreditsCharacteristic == null) { 523 | onSerialConnectError(new IOException("write credits characteristic not found")); 524 | return false; 525 | } 526 | if (!gatt.setCharacteristicNotification(readCreditsCharacteristic, true)) { 527 | onSerialConnectError(new IOException("no notification for read credits characteristic")); 528 | return false; 529 | } 530 | BluetoothGattDescriptor readCreditsDescriptor = readCreditsCharacteristic.getDescriptor(BLUETOOTH_LE_CCCD); 531 | if (readCreditsDescriptor == null) { 532 | onSerialConnectError(new IOException("no CCCD descriptor for read credits characteristic")); 533 | return false; 534 | } 535 | readCreditsDescriptor.setValue(BluetoothGattDescriptor.ENABLE_INDICATION_VALUE); 536 | Log.d(TAG,"writing read credits characteristic descriptor"); 537 | if (!gatt.writeDescriptor(readCreditsDescriptor)) { 538 | onSerialConnectError(new IOException("read credits characteristic CCCD descriptor not writable")); 539 | return false; 540 | } 541 | Log.d(TAG, "writing read credits characteristic descriptor"); 542 | return false; 543 | // continues asynchronously in connectCharacteristics2 544 | } 545 | 546 | @Override 547 | void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { 548 | if(descriptor.getCharacteristic() == readCreditsCharacteristic) { 549 | Log.d(TAG, "writing read credits characteristic descriptor finished, status=" + status); 550 | if (status != BluetoothGatt.GATT_SUCCESS) { 551 | onSerialConnectError(new IOException("write credits descriptor failed")); 552 | } else { 553 | connectCharacteristics2(gatt); 554 | } 555 | } 556 | if(descriptor.getCharacteristic() == readCharacteristic) { 557 | Log.d(TAG, "writing read characteristic descriptor finished, status=" + status); 558 | if (status == BluetoothGatt.GATT_SUCCESS) { 559 | readCharacteristic.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE); 560 | writeCharacteristic.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE); 561 | grantReadCredits(); 562 | // grantReadCredits includes gatt.writeCharacteristic(writeCreditsCharacteristic) 563 | // but we do not have to wait for confirmation, as it is the last write of connect phase. 564 | } 565 | } 566 | } 567 | 568 | @Override 569 | void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { 570 | if(characteristic == readCreditsCharacteristic) { // NOPMD - test object identity 571 | int newCredits = readCreditsCharacteristic.getValue()[0]; 572 | synchronized (writeBuffer) { 573 | writeCredits += newCredits; 574 | } 575 | Log.d(TAG, "got write credits +"+newCredits+" ="+writeCredits); 576 | 577 | if (!writePending && !writeBuffer.isEmpty()) { 578 | Log.d(TAG, "resume blocked write"); 579 | writeNext(); 580 | } 581 | } 582 | if(characteristic == readCharacteristic) { // NOPMD - test object identity 583 | grantReadCredits(); 584 | Log.d(TAG, "read, credits=" + readCredits); 585 | } 586 | } 587 | 588 | @Override 589 | void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { 590 | if(characteristic == writeCharacteristic) { // NOPMD - test object identity 591 | synchronized (writeBuffer) { 592 | if (writeCredits > 0) 593 | writeCredits -= 1; 594 | } 595 | Log.d(TAG, "write finished, credits=" + writeCredits); 596 | } 597 | if(characteristic == writeCreditsCharacteristic) { // NOPMD - test object identity 598 | Log.d(TAG,"write credits finished, status="+status); 599 | } 600 | } 601 | 602 | @Override 603 | boolean canWrite() { 604 | if(writeCredits > 0) 605 | return true; 606 | Log.d(TAG, "no write credits"); 607 | return false; 608 | } 609 | 610 | @Override 611 | void disconnect() { 612 | readCreditsCharacteristic = null; 613 | writeCreditsCharacteristic = null; 614 | } 615 | 616 | private void grantReadCredits() { 617 | final int minReadCredits = 16; 618 | final int maxReadCredits = 64; 619 | if(readCredits > 0) 620 | readCredits -= 1; 621 | if(readCredits <= minReadCredits) { 622 | int newCredits = maxReadCredits - readCredits; 623 | readCredits += newCredits; 624 | byte[] data = new byte[] {(byte)newCredits}; 625 | Log.d(TAG, "grant read credits +"+newCredits+" ="+readCredits); 626 | writeCreditsCharacteristic.setValue(data); 627 | if (!gatt.writeCharacteristic(writeCreditsCharacteristic)) { 628 | if(connected) 629 | onSerialIoError(new IOException("write read credits failed")); 630 | else 631 | onSerialConnectError(new IOException("write read credits failed")); 632 | } 633 | } 634 | } 635 | 636 | } 637 | 638 | } 639 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/java/de/kai_morich/simple_bluetooth_le_terminal/TextUtil.java: -------------------------------------------------------------------------------- 1 | package de.kai_morich.simple_bluetooth_le_terminal; 2 | 3 | import android.text.Editable; 4 | import android.text.InputType; 5 | import android.text.Spannable; 6 | import android.text.SpannableStringBuilder; 7 | import android.text.TextWatcher; 8 | import android.text.style.BackgroundColorSpan; 9 | import android.widget.TextView; 10 | 11 | import androidx.annotation.ColorInt; 12 | 13 | import java.io.ByteArrayOutputStream; 14 | 15 | public final class TextUtil { 16 | 17 | @ColorInt static int caretBackground = 0xff666666; 18 | 19 | public final static String newline_crlf = "\r\n"; 20 | public final static String newline_lf = "\n"; 21 | 22 | public static byte[] fromHexString(final CharSequence s) { 23 | ByteArrayOutputStream buf = new ByteArrayOutputStream(); 24 | byte b = 0; 25 | int nibble = 0; 26 | for(int pos = 0; pos='0' && c<='9') { nibble++; b *= 16; b += c-'0'; } 34 | if(c>='A' && c<='F') { nibble++; b *= 16; b += c-'A'+10; } 35 | if(c>='a' && c<='f') { nibble++; b *= 16; b += c-'a'+10; } 36 | } 37 | if(nibble>0) 38 | buf.write(b); 39 | return buf.toByteArray(); 40 | } 41 | 42 | public static String toHexString(final byte[] buf) { 43 | return toHexString(buf, 0, buf.length); 44 | } 45 | 46 | public static String toHexString(final byte[] buf, int begin, int end) { 47 | StringBuilder sb = new StringBuilder(3*(end-begin)); 48 | toHexString(sb, buf, begin, end); 49 | return sb.toString(); 50 | } 51 | 52 | public static void toHexString(StringBuilder sb, final byte[] buf) { 53 | toHexString(sb, buf, 0, buf.length); 54 | } 55 | 56 | public static void toHexString(StringBuilder sb, final byte[] buf, int begin, int end) { 57 | for(int pos=begin; pos0) 59 | sb.append(' '); 60 | int c; 61 | c = (buf[pos]&0xff) / 16; 62 | if(c >= 10) c += 'A'-10; 63 | else c += '0'; 64 | sb.append((char)c); 65 | c = (buf[pos]&0xff) % 16; 66 | if(c >= 10) c += 'A'-10; 67 | else c += '0'; 68 | sb.append((char)c); 69 | } 70 | } 71 | 72 | /** 73 | * use https://en.wikipedia.org/wiki/Caret_notation to avoid invisible control characters 74 | */ 75 | public static CharSequence toCaretString(CharSequence s, boolean keepNewline) { 76 | return toCaretString(s, keepNewline, s.length()); 77 | } 78 | 79 | public static CharSequence toCaretString(CharSequence s, boolean keepNewline, int length) { 80 | boolean found = false; 81 | for (int pos = 0; pos < length; pos++) { 82 | if (s.charAt(pos) < 32 && (!keepNewline ||s.charAt(pos)!='\n')) { 83 | found = true; 84 | break; 85 | } 86 | } 87 | if(!found) 88 | return s; 89 | SpannableStringBuilder sb = new SpannableStringBuilder(); 90 | for(int pos=0; pos= '0' && c <= '9') sb.append(c); 140 | if(c >= 'A' && c <= 'F') sb.append(c); 141 | if(c >= 'a' && c <= 'f') sb.append((char)(c+'A'-'a')); 142 | } 143 | for(i=2; i listItems = new ArrayList<>(); 53 | private ArrayAdapter listAdapter; 54 | 55 | public DevicesFragment() { 56 | leScanCallback = (device, rssi, scanRecord) -> { 57 | if(device != null && getActivity() != null) { 58 | getActivity().runOnUiThread(() -> { updateScan(device); }); 59 | } 60 | }; 61 | discoveryBroadcastReceiver = new BroadcastReceiver() { 62 | @Override 63 | public void onReceive(Context context, Intent intent) { 64 | if(BluetoothDevice.ACTION_FOUND.equals(intent.getAction())) { 65 | BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); 66 | if(device.getType() != BluetoothDevice.DEVICE_TYPE_CLASSIC && getActivity() != null) { 67 | getActivity().runOnUiThread(() -> updateScan(device)); 68 | } 69 | } 70 | if(BluetoothAdapter.ACTION_DISCOVERY_FINISHED.equals(intent.getAction())) { 71 | scanState = ScanState.DISCOVERY_FINISHED; // don't cancel again 72 | stopScan(); 73 | } 74 | } 75 | }; 76 | discoveryIntentFilter = new IntentFilter(); 77 | discoveryIntentFilter.addAction(BluetoothDevice.ACTION_FOUND); 78 | discoveryIntentFilter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED); 79 | } 80 | 81 | @Override 82 | public void onCreate(Bundle savedInstanceState) { 83 | super.onCreate(savedInstanceState); 84 | setHasOptionsMenu(true); 85 | if(getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)) 86 | bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); 87 | listAdapter = new ArrayAdapter(getActivity(), 0, listItems) { 88 | @NonNull 89 | @Override 90 | public View getView(int position, View view, @NonNull ViewGroup parent) { 91 | BluetoothDevice device = listItems.get(position); 92 | if (view == null) 93 | view = getActivity().getLayoutInflater().inflate(R.layout.device_list_item, parent, false); 94 | TextView text1 = view.findViewById(R.id.text1); 95 | TextView text2 = view.findViewById(R.id.text2); 96 | if(device.getName() == null || device.getName().isEmpty()) 97 | text1.setText(""); 98 | else 99 | text1.setText(device.getName()); 100 | text2.setText(device.getAddress()); 101 | return view; 102 | } 103 | }; 104 | } 105 | 106 | @Override 107 | public void onActivityCreated(Bundle savedInstanceState) { 108 | super.onActivityCreated(savedInstanceState); 109 | setListAdapter(null); 110 | View header = getActivity().getLayoutInflater().inflate(R.layout.device_list_header, null, false); 111 | getListView().addHeaderView(header, null, false); 112 | setEmptyText("initializing..."); 113 | ((TextView) getListView().getEmptyView()).setTextSize(18); 114 | setListAdapter(listAdapter); 115 | } 116 | 117 | @Override 118 | public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) { 119 | inflater.inflate(R.menu.menu_devices, menu); 120 | this.menu = menu; 121 | if (bluetoothAdapter == null) { 122 | menu.findItem(R.id.bt_settings).setEnabled(false); 123 | menu.findItem(R.id.ble_scan).setEnabled(false); 124 | } else if(!bluetoothAdapter.isEnabled()) { 125 | menu.findItem(R.id.ble_scan).setEnabled(false); 126 | } 127 | } 128 | 129 | @Override 130 | public void onResume() { 131 | super.onResume(); 132 | 133 | getActivity().registerReceiver(discoveryBroadcastReceiver, discoveryIntentFilter); 134 | getActivity().setTitle(R.string.devices_title); 135 | 136 | if(bluetoothAdapter == null) { 137 | setEmptyText(""); 138 | } else if(!bluetoothAdapter.isEnabled()) { 139 | setEmptyText(""); 140 | if (menu != null) { 141 | listItems.clear(); 142 | listAdapter.notifyDataSetChanged(); 143 | menu.findItem(R.id.ble_scan).setEnabled(false); 144 | } 145 | } else { 146 | setEmptyText(""); 147 | if (menu != null) 148 | menu.findItem(R.id.ble_scan).setEnabled(true); 149 | } 150 | } 151 | 152 | @Override 153 | public void onPause() { 154 | super.onPause(); 155 | stopScan(); 156 | getActivity().unregisterReceiver(discoveryBroadcastReceiver); 157 | } 158 | 159 | @Override 160 | public void onDestroyView() { 161 | super.onDestroyView(); 162 | menu = null; 163 | } 164 | 165 | @Override 166 | public boolean onOptionsItemSelected(MenuItem item) { 167 | int id = item.getItemId(); 168 | if (id == R.id.ble_scan) { 169 | startScan(); 170 | return true; 171 | } else if (id == R.id.ble_scan_stop) { 172 | stopScan(); 173 | return true; 174 | } else if (id == R.id.bt_settings) { 175 | Intent intent = new Intent(); 176 | intent.setAction(android.provider.Settings.ACTION_BLUETOOTH_SETTINGS); 177 | startActivity(intent); 178 | return true; 179 | } else { 180 | return super.onOptionsItemSelected(item); 181 | } 182 | } 183 | 184 | @SuppressLint("StaticFieldLeak") // AsyncTask needs reference to this fragment 185 | private void startScan() { 186 | if(scanState != ScanState.NONE) 187 | return; 188 | scanState = ScanState.LE_SCAN; 189 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 190 | if (getActivity().checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { 191 | scanState = ScanState.NONE; 192 | AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); 193 | builder.setTitle(R.string.location_permission_title); 194 | builder.setMessage(R.string.location_permission_message); 195 | builder.setPositiveButton(android.R.string.ok, 196 | (dialog, which) -> requestPermissions(new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, 0)); 197 | builder.show(); 198 | return; 199 | } 200 | LocationManager locationManager = (LocationManager) getActivity().getSystemService(Context.LOCATION_SERVICE); 201 | boolean locationEnabled = false; 202 | try { 203 | locationEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER); 204 | } catch(Exception ignored) {} 205 | try { 206 | locationEnabled |= locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER); 207 | } catch(Exception ignored) {} 208 | if(!locationEnabled) 209 | scanState = ScanState.DISCOVERY; 210 | // Starting with Android 6.0 a bluetooth scan requires ACCESS_COARSE_LOCATION permission, but that's not all! 211 | // LESCAN also needs enabled 'location services', whereas DISCOVERY works without. 212 | // Most users think of GPS as 'location service', but it includes more, as we see here. 213 | // Instead of asking the user to enable something they consider unrelated, 214 | // we fall back to the older API that scans for bluetooth classic _and_ LE 215 | // sometimes the older API returns less results or slower 216 | } 217 | listItems.clear(); 218 | listAdapter.notifyDataSetChanged(); 219 | setEmptyText(""); 220 | menu.findItem(R.id.ble_scan).setVisible(false); 221 | menu.findItem(R.id.ble_scan_stop).setVisible(true); 222 | if(scanState == ScanState.LE_SCAN) { 223 | leScanStopHandler.postDelayed(this::stopScan, LE_SCAN_PERIOD); 224 | new AsyncTask() { 225 | @Override 226 | protected Void doInBackground(Void[] params) { 227 | bluetoothAdapter.startLeScan(null, leScanCallback); 228 | return null; 229 | } 230 | }.execute(); // start async to prevent blocking UI, because startLeScan sometimes take some seconds 231 | } else { 232 | bluetoothAdapter.startDiscovery(); 233 | } 234 | } 235 | 236 | @Override 237 | public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { 238 | // ignore requestCode as there is only one in this fragment 239 | if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { 240 | new Handler(Looper.getMainLooper()).postDelayed(this::startScan,1); // run after onResume to avoid wrong empty-text 241 | } else { 242 | AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); 243 | builder.setTitle(getText(R.string.location_denied_title)); 244 | builder.setMessage(getText(R.string.location_denied_message)); 245 | builder.setPositiveButton(android.R.string.ok, null); 246 | builder.show(); 247 | } 248 | } 249 | 250 | private void updateScan(BluetoothDevice device) { 251 | if(scanState == ScanState.NONE) 252 | return; 253 | if(!listItems.contains(device)) { 254 | listItems.add(device); 255 | Collections.sort(listItems, DevicesFragment::compareTo); 256 | listAdapter.notifyDataSetChanged(); 257 | } 258 | } 259 | 260 | private void stopScan() { 261 | if(scanState == ScanState.NONE) 262 | return; 263 | setEmptyText(""); 264 | if(menu != null) { 265 | menu.findItem(R.id.ble_scan).setVisible(true); 266 | menu.findItem(R.id.ble_scan_stop).setVisible(false); 267 | } 268 | switch(scanState) { 269 | case LE_SCAN: 270 | leScanStopHandler.removeCallbacks(this::stopScan); 271 | bluetoothAdapter.stopLeScan(leScanCallback); 272 | break; 273 | case DISCOVERY: 274 | bluetoothAdapter.cancelDiscovery(); 275 | break; 276 | default: 277 | // already canceled 278 | } 279 | scanState = ScanState.NONE; 280 | 281 | } 282 | 283 | @Override 284 | public void onListItemClick(@NonNull ListView l, @NonNull View v, int position, long id) { 285 | stopScan(); 286 | BluetoothDevice device = listItems.get(position-1); 287 | Bundle args = new Bundle(); 288 | args.putString("device", device.getAddress()); 289 | // Fragment fragment = new TerminalFragment(); 290 | Fragment fragment = new JoystickFragment(); 291 | fragment.setArguments(args); 292 | getParentFragmentManager().beginTransaction() 293 | .replace(R.id.fragment, fragment, "terminal") 294 | .addToBackStack(null) 295 | .commit(); 296 | } 297 | 298 | /** 299 | * sort by name, then address. sort named devices first 300 | */ 301 | static int compareTo(BluetoothDevice a, BluetoothDevice b) { 302 | boolean aValid = a.getName()!=null && !a.getName().isEmpty(); 303 | boolean bValid = b.getName()!=null && !b.getName().isEmpty(); 304 | if(aValid && bValid) { 305 | int ret = a.getName().compareTo(b.getName()); 306 | if (ret != 0) return ret; 307 | return a.getAddress().compareTo(b.getAddress()); 308 | } 309 | if(aValid) return -1; 310 | if(bValid) return +1; 311 | return a.getAddress().compareTo(b.getAddress()); 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/java/de/kai_morich/simple_bluetooth_le_terminal/fragments/JoystickFragment.java: -------------------------------------------------------------------------------- 1 | package de.kai_morich.simple_bluetooth_le_terminal.fragments; 2 | 3 | import android.os.Bundle; 4 | import android.util.Log; 5 | import android.util.Pair; 6 | import android.view.LayoutInflater; 7 | import android.view.Menu; 8 | import android.view.MenuInflater; 9 | import android.view.MenuItem; 10 | import android.view.View; 11 | import android.view.ViewGroup; 12 | import android.widget.TextView; 13 | import android.widget.Toast; 14 | 15 | import androidx.annotation.NonNull; 16 | import androidx.annotation.Nullable; 17 | 18 | import java.io.IOException; 19 | import java.util.concurrent.TimeUnit; 20 | 21 | import de.kai_morich.simple_bluetooth_le_terminal.R; 22 | import de.kai_morich.simple_bluetooth_le_terminal.TextUtil; 23 | import de.kai_morich.simple_bluetooth_le_terminal.views.JoystickView; 24 | import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; 25 | import io.reactivex.rxjava3.disposables.Disposable; 26 | import io.reactivex.rxjava3.schedulers.Schedulers; 27 | import io.reactivex.rxjava3.subjects.PublishSubject; 28 | 29 | import static de.kai_morich.simple_bluetooth_le_terminal.fragments.PIDSettingsFragment.DIVISOR; 30 | 31 | public class JoystickFragment extends SerialEnabledFragment implements JoystickView.JoystickListener { 32 | 33 | public static final String TAG = JoystickFragment.class.getSimpleName(); 34 | 35 | public static final float VELOCITY_MAX = 10.0f; 36 | public static final float STEERING_MAX = 2.0f; 37 | 38 | private TextView velocityView; 39 | private TextView steeringView; 40 | 41 | private PublishSubject> joystickStream = PublishSubject.create(); 42 | private Disposable subscription; 43 | 44 | @Override 45 | public void onCreate(@Nullable Bundle savedInstanceState) { 46 | super.onCreate(savedInstanceState); 47 | setHasOptionsMenu(true); 48 | deviceAddress = getArguments().getString("device"); 49 | } 50 | 51 | @Nullable 52 | @Override 53 | public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { 54 | View view = inflater.inflate(R.layout.fragment_joystick, container, false); 55 | velocityView = view.findViewById(R.id.velocity); 56 | steeringView = view.findViewById(R.id.steering); 57 | ((JoystickView) view.findViewById(R.id.joystick_view)).setJoystickCallback(this); 58 | return view; 59 | } 60 | 61 | @Override 62 | public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) { 63 | inflater.inflate(R.menu.menu_joystick, menu); 64 | } 65 | 66 | @Override 67 | public boolean onOptionsItemSelected(MenuItem item) { 68 | int id = item.getItemId(); 69 | if (id == R.id.pid_settings) { 70 | disconnect(); 71 | PIDSettingsFragment fragment = new PIDSettingsFragment(); 72 | 73 | Bundle args = new Bundle(); 74 | args.putString("device", deviceAddress); 75 | fragment.setArguments(args); 76 | 77 | getParentFragmentManager().beginTransaction() 78 | .replace(R.id.fragment, fragment, PIDSettingsFragment.FRAGMENT_TAG) 79 | .addToBackStack(null) 80 | .commit(); 81 | return true; 82 | } else { 83 | return super.onOptionsItemSelected(item); 84 | } 85 | } 86 | 87 | @Override 88 | public void onResume() { 89 | super.onResume(); 90 | getActivity().setTitle(R.string.joystick_title); 91 | subscription = joystickStream.subscribeOn(Schedulers.newThread()) 92 | .observeOn(AndroidSchedulers.mainThread()) 93 | .throttleLast(100, TimeUnit.MILLISECONDS) 94 | .subscribe((pair) -> sendJoystickState(pair.first, pair.second)); 95 | } 96 | 97 | @Override 98 | public void onPause() { 99 | super.onPause(); 100 | if (subscription != null) { 101 | subscription.dispose(); 102 | } 103 | } 104 | 105 | @Override 106 | public void onJoystickMoved(float xPercent, float yPercent, int id) { 107 | final float velocity = -yPercent * VELOCITY_MAX; 108 | final float steering = xPercent * STEERING_MAX; 109 | 110 | velocityView.setText(String.format("Velocity: %.2f", velocity)); 111 | steeringView.setText(String.format("Steering: %.2f", steering)); 112 | joystickStream.onNext(Pair.create(velocity, steering)); 113 | } 114 | 115 | @Override 116 | public void onSerialConnect() { 117 | super.onSerialConnect(); 118 | Toast.makeText(getActivity(), "Connected", Toast.LENGTH_SHORT).show(); 119 | } 120 | 121 | @Override 122 | public void onSerialRead(byte[] data) { 123 | Log.i(TAG, "onSerialRead: " + new String(data)); 124 | } 125 | 126 | private void sendJoystickState(float velocity, float steering) { 127 | if (service != null) { 128 | if (!isConnected()) { 129 | return; 130 | } 131 | StringBuilder command = new StringBuilder("c") 132 | .append((int) (velocity * DIVISOR)) 133 | .append(';') 134 | .append((int) (steering * DIVISOR)) 135 | .append(TextUtil.newline_crlf); 136 | try { 137 | service.write(command.toString().getBytes()); 138 | } catch (IOException e) { 139 | onSerialIoError(e); 140 | } 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/java/de/kai_morich/simple_bluetooth_le_terminal/fragments/PIDSettingsFragment.java: -------------------------------------------------------------------------------- 1 | package de.kai_morich.simple_bluetooth_le_terminal.fragments; 2 | 3 | import android.os.Bundle; 4 | import android.util.Log; 5 | import android.view.LayoutInflater; 6 | import android.view.View; 7 | import android.view.ViewGroup; 8 | import android.widget.Button; 9 | import android.widget.EditText; 10 | import android.widget.Toast; 11 | 12 | import androidx.annotation.NonNull; 13 | import androidx.annotation.Nullable; 14 | 15 | import java.io.IOException; 16 | import java.util.ArrayList; 17 | import java.util.List; 18 | import java.util.regex.Pattern; 19 | 20 | import de.kai_morich.simple_bluetooth_le_terminal.R; 21 | import de.kai_morich.simple_bluetooth_le_terminal.TextUtil; 22 | 23 | public class PIDSettingsFragment extends SerialEnabledFragment { 24 | 25 | private static final String TAG = PIDSettingsFragment.class.getSimpleName(); 26 | 27 | public static final String FRAGMENT_TAG = "pid_settings"; 28 | 29 | private static final String REQUEST_PID_SETTINGS_COMMAND = "r" + TextUtil.newline_crlf; 30 | public static final double DIVISOR = 10000.0; 31 | 32 | private static final Pattern SETTINGS_PACKET_REGEX = Pattern.compile("^(\\-?\\d+)+;(\\-?\\d+)+;(\\-?\\d+)+;(\\-?\\d+)+;(\\-?\\d+)+;(\\-?\\d+)$"); 33 | 34 | private List pidSettings = new ArrayList<>(); 35 | 36 | private Button cancelButton; 37 | private Button saveButton; 38 | 39 | private byte[] packet = new byte[1024]; 40 | private int packetSize = 0; 41 | 42 | @Override 43 | public void onCreate(@Nullable Bundle savedInstanceState) { 44 | super.onCreate(savedInstanceState); 45 | deviceAddress = getArguments().getString("device"); 46 | } 47 | 48 | @Override 49 | public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { 50 | View view = inflater.inflate(R.layout.fragment_pid_settings, container, false); 51 | int[] ids = new int[]{ 52 | R.id.pid_balance_kp, 53 | R.id.pid_balance_kd, 54 | R.id.pid_balance_ki, 55 | R.id.pid_velocity_kp, 56 | R.id.pid_velocity_kd, 57 | R.id.pid_velocity_ki, 58 | }; 59 | for (int id : ids) { 60 | pidSettings.add((EditText) view.findViewById(id)); 61 | } 62 | 63 | saveButton = (Button) view.findViewById(R.id.pid_settings_button_save); 64 | saveButton.setOnClickListener(v -> savePIDSettings()); 65 | cancelButton = (Button) view.findViewById(R.id.pid_settings_button_cancel); 66 | cancelButton.setOnClickListener(v -> getParentFragmentManager().popBackStack()); 67 | 68 | return view; 69 | } 70 | 71 | @Override 72 | public void onResume() { 73 | super.onResume(); 74 | getActivity().setTitle(R.string.pid_settings_title); 75 | requestPIDSettings(); 76 | } 77 | 78 | private void applyPIDSettings(double[] settings) { 79 | for (int i = 0; i < settings.length; i++) { 80 | pidSettings.get(i).setText(String.valueOf(settings[i])); 81 | } 82 | } 83 | 84 | @Override 85 | public void onSerialConnect() { 86 | super.onSerialConnect(); 87 | Toast.makeText(getActivity(), "Connected", Toast.LENGTH_SHORT).show(); 88 | requestPIDSettings(); 89 | } 90 | 91 | @Override 92 | public void onSerialRead(byte[] data) { 93 | for (byte b : data) { 94 | if (b == '\r') { 95 | String packetStr = new String(packet, 0, packetSize); 96 | Log.i(TAG, packetStr); 97 | handlePacket(packetStr); 98 | packetSize = 0; 99 | } else if (b == '\n') { 100 | continue; 101 | } else { 102 | packet[packetSize++] = b; 103 | } 104 | } 105 | } 106 | 107 | private void handlePacket(String packet) { 108 | if (!SETTINGS_PACKET_REGEX.matcher(packet).matches()) { 109 | Toast.makeText(getActivity(), "Unrecognized packet pattern: " + packet, Toast.LENGTH_SHORT).show(); 110 | return; 111 | } 112 | double[] settings = new double[6]; 113 | String[] split = packet.split(";"); 114 | for (int i = 0; i < split.length; i++) { 115 | settings[i] = Double.parseDouble(split[i]) / DIVISOR; 116 | } 117 | applyPIDSettings(settings); 118 | } 119 | 120 | private void requestPIDSettings() { 121 | try { 122 | if (service != null) { 123 | service.write(REQUEST_PID_SETTINGS_COMMAND.getBytes()); 124 | } else { 125 | Log.i("PIDSettings", "service == null"); 126 | } 127 | } catch (IOException e) { 128 | onSerialIoError(e); 129 | } 130 | } 131 | 132 | private void savePIDSettings() { 133 | StringBuilder builder = new StringBuilder("s"); 134 | for (EditText text : pidSettings) { 135 | long value = (long) (Double.parseDouble(text.getText().toString()) * DIVISOR); 136 | builder.append(value).append(';'); 137 | } 138 | builder.setLength(builder.length() - 1); 139 | builder.append(TextUtil.newline_crlf); 140 | if (service != null) { 141 | try { 142 | service.write(builder.toString().getBytes()); 143 | } catch (IOException e) { 144 | onSerialIoError(e); 145 | } 146 | } 147 | Toast.makeText(getActivity(), "Saved", Toast.LENGTH_SHORT).show(); 148 | } 149 | } -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/java/de/kai_morich/simple_bluetooth_le_terminal/fragments/SerialEnabledFragment.java: -------------------------------------------------------------------------------- 1 | package de.kai_morich.simple_bluetooth_le_terminal.fragments; 2 | 3 | import android.app.Activity; 4 | import android.bluetooth.BluetoothAdapter; 5 | import android.bluetooth.BluetoothDevice; 6 | import android.content.ComponentName; 7 | import android.content.Context; 8 | import android.content.Intent; 9 | import android.content.ServiceConnection; 10 | import android.os.IBinder; 11 | import android.util.Log; 12 | 13 | import androidx.annotation.NonNull; 14 | import androidx.fragment.app.Fragment; 15 | 16 | import de.kai_morich.simple_bluetooth_le_terminal.R; 17 | import de.kai_morich.simple_bluetooth_le_terminal.SerialListener; 18 | import de.kai_morich.simple_bluetooth_le_terminal.SerialService; 19 | import de.kai_morich.simple_bluetooth_le_terminal.SerialSocket; 20 | 21 | public abstract class SerialEnabledFragment extends Fragment implements ServiceConnection, SerialListener { 22 | 23 | private static final String TAG = SerialEnabledFragment.class.getSimpleName(); 24 | 25 | protected enum ConnectionStatus { 26 | CONNECTED, 27 | PENDING, 28 | NOT_CONNECTED 29 | } 30 | 31 | protected String deviceAddress; 32 | protected SerialService service; 33 | private ConnectionStatus connectionStatus; 34 | @Override 35 | public void onDestroy() { 36 | if (connectionStatus != ConnectionStatus.NOT_CONNECTED) { 37 | disconnect(); 38 | } 39 | getActivity().stopService(new Intent(getActivity(), SerialService.class)); 40 | super.onDestroy(); 41 | } 42 | 43 | @Override 44 | public void onStart() { 45 | super.onStart(); 46 | if (service != null) { 47 | service.attach(this); 48 | } else { 49 | getActivity().startService(new Intent(getActivity(), SerialService.class)); // prevents service destroy on unbind from recreated activity caused by orientation change 50 | } 51 | } 52 | 53 | @Override 54 | public void onStop() { 55 | if (service != null && !getActivity().isChangingConfigurations()) { 56 | service.detach(); 57 | } 58 | super.onStop(); 59 | } 60 | 61 | @SuppressWarnings("deprecation") 62 | // onAttach(context) was added with API 23. onAttach(activity) works for all API versions 63 | @Override 64 | public void onAttach(@NonNull Activity activity) { 65 | super.onAttach(activity); 66 | getActivity().bindService(new Intent(getActivity(), SerialService.class), this, Context.BIND_AUTO_CREATE); 67 | } 68 | 69 | @Override 70 | public void onDetach() { 71 | try { 72 | getActivity().unbindService(this); 73 | } catch (Exception ignored) { 74 | } 75 | super.onDetach(); 76 | } 77 | 78 | @Override 79 | public void onResume() { 80 | super.onResume(); 81 | getActivity().setTitle(R.string.terminal_title); 82 | if (service != null && !isConnected()) { 83 | getActivity().runOnUiThread(this::connect); 84 | } 85 | } 86 | 87 | @Override 88 | public void onServiceConnected(ComponentName name, IBinder binder) { 89 | Log.i(TAG, "onServiceConnected"); 90 | 91 | service = ((SerialService.SerialBinder) binder).getService(); 92 | service.attach(this); 93 | if (isResumed() && !isConnected()) { 94 | getActivity().runOnUiThread(this::connect); 95 | } 96 | } 97 | 98 | @Override 99 | public void onServiceDisconnected(ComponentName name) { 100 | service = null; 101 | } 102 | 103 | protected void connect() { 104 | Log.i(TAG, "connect()"); 105 | try { 106 | BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); 107 | BluetoothDevice device = bluetoothAdapter.getRemoteDevice(deviceAddress); 108 | connectionStatus = ConnectionStatus.PENDING; 109 | SerialSocket socket = new SerialSocket(getActivity().getApplicationContext(), device); 110 | service.connect(socket); 111 | } catch (Exception e) { 112 | onSerialConnectError(e); 113 | } 114 | } 115 | 116 | protected void disconnect() { 117 | Log.i(TAG, "disconnect()"); 118 | connectionStatus = ConnectionStatus.NOT_CONNECTED; 119 | service.disconnect(); 120 | } 121 | 122 | @Override 123 | public void onSerialConnect() { 124 | Log.i(TAG, "onSerialConnect(): connected"); 125 | connectionStatus = ConnectionStatus.CONNECTED; 126 | } 127 | 128 | @Override 129 | public void onSerialConnectError(Exception e) { 130 | disconnect(); 131 | } 132 | 133 | @Override 134 | public void onSerialIoError(Exception e) { 135 | disconnect(); 136 | } 137 | 138 | public boolean isConnected() { 139 | return connectionStatus == ConnectionStatus.CONNECTED; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/java/de/kai_morich/simple_bluetooth_le_terminal/fragments/TerminalFragment.java: -------------------------------------------------------------------------------- 1 | package de.kai_morich.simple_bluetooth_le_terminal.fragments; 2 | 3 | import android.app.AlertDialog; 4 | import android.os.Bundle; 5 | import android.text.Editable; 6 | import android.text.Spannable; 7 | import android.text.SpannableStringBuilder; 8 | import android.text.method.ScrollingMovementMethod; 9 | import android.text.style.ForegroundColorSpan; 10 | import android.view.LayoutInflater; 11 | import android.view.Menu; 12 | import android.view.MenuInflater; 13 | import android.view.MenuItem; 14 | import android.view.View; 15 | import android.view.ViewGroup; 16 | import android.widget.TextView; 17 | import android.widget.Toast; 18 | 19 | import androidx.annotation.NonNull; 20 | import androidx.annotation.Nullable; 21 | 22 | import de.kai_morich.simple_bluetooth_le_terminal.R; 23 | import de.kai_morich.simple_bluetooth_le_terminal.TextUtil; 24 | 25 | public class TerminalFragment extends SerialEnabledFragment { 26 | 27 | private TextView receiveText; 28 | private TextView sendText; 29 | private TextUtil.HexWatcher hexWatcher; 30 | 31 | private boolean hexEnabled = false; 32 | private boolean pendingNewline = false; 33 | private String newline = TextUtil.newline_crlf; 34 | 35 | /* 36 | * Lifecycle 37 | */ 38 | @Override 39 | public void onCreate(@Nullable Bundle savedInstanceState) { 40 | super.onCreate(savedInstanceState); 41 | setHasOptionsMenu(true); 42 | setRetainInstance(true); 43 | deviceAddress = getArguments().getString("device"); 44 | } 45 | 46 | /* 47 | * UI 48 | */ 49 | @Override 50 | public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { 51 | View view = inflater.inflate(R.layout.fragment_terminal, container, false); 52 | receiveText = view.findViewById(R.id.receive_text); // TextView performance decreases with number of spans 53 | receiveText.setTextColor(getResources().getColor(R.color.colorRecieveText)); // set as default color to reduce number of spans 54 | receiveText.setMovementMethod(ScrollingMovementMethod.getInstance()); 55 | 56 | sendText = view.findViewById(R.id.send_text); 57 | hexWatcher = new TextUtil.HexWatcher(sendText); 58 | hexWatcher.enable(hexEnabled); 59 | sendText.addTextChangedListener(hexWatcher); 60 | sendText.setHint(hexEnabled ? "HEX mode" : ""); 61 | 62 | View sendBtn = view.findViewById(R.id.send_btn); 63 | sendBtn.setOnClickListener(v -> send(sendText.getText().toString())); 64 | return view; 65 | } 66 | 67 | @Override 68 | public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) { 69 | inflater.inflate(R.menu.menu_terminal, menu); 70 | menu.findItem(R.id.hex).setChecked(hexEnabled); 71 | } 72 | 73 | @Override 74 | public boolean onOptionsItemSelected(MenuItem item) { 75 | int id = item.getItemId(); 76 | if (id == R.id.clear) { 77 | receiveText.setText(""); 78 | return true; 79 | } else if (id == R.id.newline) { 80 | String[] newlineNames = getResources().getStringArray(R.array.newline_names); 81 | String[] newlineValues = getResources().getStringArray(R.array.newline_values); 82 | int pos = java.util.Arrays.asList(newlineValues).indexOf(newline); 83 | AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); 84 | builder.setTitle("Newline"); 85 | builder.setSingleChoiceItems(newlineNames, pos, (dialog, item1) -> { 86 | newline = newlineValues[item1]; 87 | dialog.dismiss(); 88 | }); 89 | builder.create().show(); 90 | return true; 91 | } else if (id == R.id.hex) { 92 | hexEnabled = !hexEnabled; 93 | sendText.setText(""); 94 | hexWatcher.enable(hexEnabled); 95 | sendText.setHint(hexEnabled ? "HEX mode" : ""); 96 | item.setChecked(hexEnabled); 97 | return true; 98 | } else if (id == R.id.pid_settings) { 99 | disconnect(); 100 | PIDSettingsFragment fragment = new PIDSettingsFragment(); 101 | 102 | Bundle args = new Bundle(); 103 | args.putString("device", deviceAddress); 104 | fragment.setArguments(args); 105 | 106 | getParentFragmentManager().beginTransaction() 107 | .replace(R.id.fragment, fragment, PIDSettingsFragment.FRAGMENT_TAG) 108 | .addToBackStack(null) 109 | .commit(); 110 | return true; 111 | } else { 112 | return super.onOptionsItemSelected(item); 113 | } 114 | } 115 | 116 | /* 117 | * Serial + UI 118 | */ 119 | 120 | private void send(String str) { 121 | if(!isConnected()) { 122 | Toast.makeText(getActivity(), "not connected", Toast.LENGTH_SHORT).show(); 123 | return; 124 | } 125 | try { 126 | String msg; 127 | byte[] data; 128 | if(hexEnabled) { 129 | StringBuilder sb = new StringBuilder(); 130 | TextUtil.toHexString(sb, TextUtil.fromHexString(str)); 131 | TextUtil.toHexString(sb, newline.getBytes()); 132 | msg = sb.toString(); 133 | data = TextUtil.fromHexString(msg); 134 | } else { 135 | msg = str; 136 | data = (str + newline).getBytes(); 137 | } 138 | SpannableStringBuilder spn = new SpannableStringBuilder(msg + '\n'); 139 | spn.setSpan(new ForegroundColorSpan(getResources().getColor(R.color.colorSendText)), 0, spn.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 140 | receiveText.append(spn); 141 | service.write(data); 142 | } catch (Exception e) { 143 | onSerialIoError(e); 144 | } 145 | } 146 | 147 | private void receive(byte[] data) { 148 | if(hexEnabled) { 149 | receiveText.append(TextUtil.toHexString(data) + '\n'); 150 | } else { 151 | String msg = new String(data); 152 | if(newline.equals(TextUtil.newline_crlf) && msg.length() > 0) { 153 | // don't show CR as ^M if directly before LF 154 | msg = msg.replace(TextUtil.newline_crlf, TextUtil.newline_lf); 155 | // special handling if CR and LF come in separate fragments 156 | if (pendingNewline && msg.charAt(0) == '\n') { 157 | Editable edt = receiveText.getEditableText(); 158 | if (edt != null && edt.length() > 1) 159 | edt.replace(edt.length() - 2, edt.length(), ""); 160 | } 161 | pendingNewline = msg.charAt(msg.length() - 1) == '\r'; 162 | } 163 | receiveText.append(TextUtil.toCaretString(msg, newline.length() != 0)); 164 | } 165 | } 166 | 167 | private void status(String str) { 168 | SpannableStringBuilder spn = new SpannableStringBuilder(str + '\n'); 169 | spn.setSpan(new ForegroundColorSpan(getResources().getColor(R.color.colorStatusText)), 0, spn.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 170 | receiveText.append(spn); 171 | } 172 | 173 | /* 174 | * SerialListener 175 | */ 176 | @Override 177 | public void onSerialConnect() { 178 | super.onSerialConnect(); 179 | status("connected"); 180 | } 181 | 182 | @Override 183 | public void onSerialConnectError(Exception e) { 184 | status("connection failed: " + e.getMessage()); 185 | super.onSerialConnectError(e); 186 | } 187 | 188 | @Override 189 | public void onSerialRead(byte[] data) { 190 | receive(data); 191 | } 192 | 193 | @Override 194 | public void onSerialIoError(Exception e) { 195 | status("connection lost: " + e.getMessage()); 196 | super.onSerialIoError(e); 197 | } 198 | 199 | } 200 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/java/de/kai_morich/simple_bluetooth_le_terminal/views/JoystickView.java: -------------------------------------------------------------------------------- 1 | package de.kai_morich.simple_bluetooth_le_terminal.views; 2 | 3 | import android.content.Context; 4 | import android.graphics.Canvas; 5 | import android.graphics.Color; 6 | import android.graphics.Paint; 7 | import android.graphics.PorterDuff; 8 | import android.util.AttributeSet; 9 | import android.util.Log; 10 | import android.view.MotionEvent; 11 | import android.view.SurfaceHolder; 12 | import android.view.SurfaceView; 13 | import android.view.View; 14 | 15 | public class JoystickView extends SurfaceView implements SurfaceHolder.Callback, View.OnTouchListener { 16 | private float centerX; 17 | private float centerY; 18 | private float baseRadius; 19 | private float hatRadius; 20 | private JoystickListener joystickCallback; 21 | private final int ratio = 5; //The smaller, the more shading will occur 22 | 23 | private void setupDimensions() { 24 | centerX = getWidth() / 2; 25 | centerY = getHeight() / 2; 26 | baseRadius = Math.min(getWidth(), getHeight()) / 3; 27 | hatRadius = Math.min(getWidth(), getHeight()) / 5; 28 | } 29 | 30 | public JoystickView(Context context) { 31 | super(context); 32 | getHolder().addCallback(this); 33 | setOnTouchListener(this); 34 | if (context instanceof JoystickListener) { 35 | setJoystickCallback((JoystickListener) context); 36 | } 37 | } 38 | 39 | public JoystickView(Context context, AttributeSet attributes, int style) { 40 | super(context, attributes, style); 41 | getHolder().addCallback(this); 42 | setOnTouchListener(this); 43 | if (context instanceof JoystickListener) { 44 | setJoystickCallback((JoystickListener) context); 45 | } 46 | } 47 | 48 | public JoystickView(Context context, AttributeSet attributes) { 49 | super(context, attributes); 50 | getHolder().addCallback(this); 51 | setOnTouchListener(this); 52 | if (context instanceof JoystickListener) { 53 | setJoystickCallback((JoystickListener) context); 54 | } 55 | } 56 | 57 | private void drawJoystick(float newX, float newY) { 58 | if (getHolder().getSurface().isValid()) { 59 | Canvas myCanvas = this.getHolder().lockCanvas(); //Stuff to draw 60 | Paint colors = new Paint(); 61 | myCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR); // Clear the BG 62 | 63 | //First determine the sin and cos of the angle that the touched point is at relative to the center of the joystick 64 | float hypotenuse = (float) Math.sqrt(Math.pow(newX - centerX, 2) + Math.pow(newY - centerY, 2)); 65 | float sin = (newY - centerY) / hypotenuse; //sin = o/h 66 | float cos = (newX - centerX) / hypotenuse; //cos = a/h 67 | 68 | //Draw the base first before shading 69 | colors.setARGB(255, 100, 100, 100); 70 | myCanvas.drawCircle(centerX, centerY, baseRadius, colors); 71 | for (int i = 1; i <= (int) (baseRadius / ratio); i++) { 72 | colors.setARGB(150 / i, 255, 0, 0); //Gradually decrease the shade of black drawn to create a nice shading effect 73 | myCanvas.drawCircle(newX - cos * hypotenuse * (ratio / baseRadius) * i, 74 | newY - sin * hypotenuse * (ratio / baseRadius) * i, i * (hatRadius * ratio / baseRadius), colors); //Gradually increase the size of the shading effect 75 | } 76 | 77 | //Drawing the joystick hat 78 | for (int i = 0; i <= (int) (hatRadius / ratio); i++) { 79 | colors.setARGB(255, (int) (i * (255 * ratio / hatRadius)), (int) (i * (255 * ratio / hatRadius)), 255); //Change the joystick color for shading purposes 80 | myCanvas.drawCircle(newX, newY, hatRadius - (float) i * (ratio) / 2, colors); //Draw the shading for the hat 81 | } 82 | 83 | getHolder().unlockCanvasAndPost(myCanvas); //Write the new drawing to the SurfaceView 84 | } 85 | } 86 | 87 | @Override 88 | public void surfaceCreated(SurfaceHolder holder) { 89 | setupDimensions(); 90 | drawJoystick(centerX, centerY); 91 | } 92 | 93 | @Override 94 | public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { 95 | 96 | } 97 | 98 | @Override 99 | public void surfaceDestroyed(SurfaceHolder holder) { 100 | 101 | } 102 | 103 | public boolean onTouch(View v, MotionEvent e) { 104 | if (v.equals(this)) { 105 | if (e.getAction() != e.ACTION_UP) { 106 | float displacement = (float) Math.sqrt((Math.pow(e.getX() - centerX, 2)) + Math.pow(e.getY() - centerY, 2)); 107 | if (displacement < baseRadius) { 108 | drawJoystick(e.getX(), e.getY()); 109 | getJoystickListenerOrDefault().onJoystickMoved((e.getX() - centerX) / baseRadius, (e.getY() - centerY) / baseRadius, getId()); 110 | } else { 111 | float ratio = baseRadius / displacement; 112 | float constrainedX = centerX + (e.getX() - centerX) * ratio; 113 | float constrainedY = centerY + (e.getY() - centerY) * ratio; 114 | drawJoystick(constrainedX, constrainedY); 115 | getJoystickListenerOrDefault().onJoystickMoved((constrainedX - centerX) / baseRadius, (constrainedY - centerY) / baseRadius, getId()); 116 | } 117 | } else { 118 | drawJoystick(centerX, centerY); 119 | getJoystickListenerOrDefault().onJoystickMoved(0, 0, getId()); 120 | } 121 | } 122 | return true; 123 | } 124 | 125 | public void setJoystickCallback(JoystickListener listener) { 126 | this.joystickCallback = listener; 127 | } 128 | 129 | protected JoystickListener getJoystickListenerOrDefault() { 130 | if (joystickCallback == null) { 131 | return JoystickListener.DEFAULT; 132 | } else { 133 | return joystickCallback; 134 | } 135 | } 136 | 137 | public interface JoystickListener { 138 | JoystickListener DEFAULT = 139 | (xPercent, yPercent, id) -> 140 | Log.i(JoystickListener.class.getSimpleName(), "ID: " + id + "; x: " + xPercent + "; y: " + yPercent); 141 | 142 | void onJoystickMoved(float xPercent, float yPercent, int id); 143 | } 144 | } -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-hdpi/ic_clear_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjor/balancing-robot/d5b1360439e0ef6449ea1aae472aa0ac51b4c578/android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-hdpi/ic_clear_white_24dp.png -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-hdpi/ic_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjor/balancing-robot/d5b1360439e0ef6449ea1aae472aa0ac51b4c578/android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-hdpi/ic_notification.png -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-mdpi/ic_clear_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjor/balancing-robot/d5b1360439e0ef6449ea1aae472aa0ac51b4c578/android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-mdpi/ic_clear_white_24dp.png -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-mdpi/ic_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjor/balancing-robot/d5b1360439e0ef6449ea1aae472aa0ac51b4c578/android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-mdpi/ic_notification.png -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-xhdpi/ic_clear_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjor/balancing-robot/d5b1360439e0ef6449ea1aae472aa0ac51b4c578/android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-xhdpi/ic_clear_white_24dp.png -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-xhdpi/ic_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjor/balancing-robot/d5b1360439e0ef6449ea1aae472aa0ac51b4c578/android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-xhdpi/ic_notification.png -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-xxhdpi/ic_clear_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjor/balancing-robot/d5b1360439e0ef6449ea1aae472aa0ac51b4c578/android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-xxhdpi/ic_clear_white_24dp.png -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-xxhdpi/ic_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjor/balancing-robot/d5b1360439e0ef6449ea1aae472aa0ac51b4c578/android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-xxhdpi/ic_notification.png -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-xxxhdpi/ic_clear_white_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjor/balancing-robot/d5b1360439e0ef6449ea1aae472aa0ac51b4c578/android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-xxxhdpi/ic_clear_white_24dp.png -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-xxxhdpi/ic_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjor/balancing-robot/d5b1360439e0ef6449ea1aae472aa0ac51b4c578/android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable-xxxhdpi/ic_notification.png -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable/ic_delete_white_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/drawable/ic_send_white_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 13 | 14 | 19 | 20 | 21 | 22 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/layout/device_list_header.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 17 | 18 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/layout/device_list_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 16 | 17 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/layout/fragment_joystick.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 14 | 18 | 25 | 29 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /android/serial-ble-rc/SimpleBluetoothLeTerminal/app/src/main/res/layout/fragment_pid_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 10 | 11 | 12 | 16 | 17 | 25 | 26 | 31 | 32 | 36 | 37 | 44 | 45 | 50 | 51 | 52 | 53 | 58 | 59 | 63 | 64 | 71 | 72 | 77 | 78 | 79 | 80 | 85 | 86 | 90 | 91 | 98 | 99 | 104 | 105 | 106 | 107 | 115 | 116 | 121 | 122 | 126 | 127 | 134 | 135 | 140 | 141 | 142 | 143 | 148 | 149 | 153 | 154 | 161 | 162 | 167 | 168 | 169 | 170 | 175 | 176 | 181 | 182 | 189 | 190 | 195 | 196 | 197 | 198 | 202 | 203 |