├── .gitignore ├── LICENSE ├── README.md ├── android ├── app │ ├── .gitignore │ ├── app.iml │ ├── build.gradle │ ├── proguard-rules.pro │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── aidl │ │ └── net │ │ │ └── maxbraun │ │ │ └── lights │ │ │ ├── ILightsService.aidl │ │ │ └── ILightsServiceCallback.aidl │ │ ├── java │ │ └── net │ │ │ └── maxbraun │ │ │ └── lights │ │ │ ├── DebugActivity.java │ │ │ ├── DebugService.java │ │ │ ├── LightsService.java │ │ │ └── RestartReceiver.java │ │ └── res │ │ ├── layout │ │ └── activity_debug.xml │ │ └── values │ │ └── strings.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── lights.iml └── settings.gradle ├── arduino └── lights.ino ├── eagle ├── eagle.epf ├── light-ring-bom.xlsx ├── light-ring-layer-mappings.txt ├── light-ring.bcream.ger ├── light-ring.bcream.gpi ├── light-ring.boardoutline.ger ├── light-ring.boardoutline.gpi ├── light-ring.bottomlayer.ger ├── light-ring.bottomlayer.gpi ├── light-ring.bottomsilkscreen.ger ├── light-ring.bottomsilkscreen.gpi ├── light-ring.bottomsoldermask.ger ├── light-ring.bottomsoldermask.gpi ├── light-ring.brd ├── light-ring.drills.dri ├── light-ring.drills.xln ├── light-ring.dru ├── light-ring.sch ├── light-ring.tcream.ger ├── light-ring.tcream.gpi ├── light-ring.toplayer.ger ├── light-ring.toplayer.gpi ├── light-ring.topsilkscreen.ger ├── light-ring.topsilkscreen.gpi ├── light-ring.topsoldermask.ger ├── light-ring.topsoldermask.gpi └── light-ring_centroid.csv └── images ├── pcb.png ├── ring.jpg └── smile.gif /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.ap_ 4 | 5 | # Files for the Dalvik VM 6 | *.dex 7 | 8 | # Java class files 9 | *.class 10 | 11 | # Generated files 12 | bin/ 13 | gen/ 14 | 15 | # Gradle files 16 | .gradle/ 17 | build/ 18 | 19 | # Local configuration file (sdk path, etc) 20 | local.properties 21 | 22 | # Proguard folder generated by Eclipse 23 | proguard/ 24 | 25 | # Log Files 26 | *.log 27 | 28 | # OSX files 29 | .DS_Store 30 | 31 | # Android Studio 32 | .idea/ 33 | .navigation/ 34 | captures/ 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Max Braun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lights 2 | 3 | Design and source code for controlling a ring of LEDs via Bluetooth LE. More context for the project [here](https://medium.com/@maxbraun/smarter-mirrors-and-how-theyre-made-327997b9eff7). 4 | 5 | [![Mirror](images/smile.gif)](https://medium.com/@maxbraun/smarter-mirrors-and-how-theyre-made-327997b9eff7) 6 | 7 | ## Eagle 8 | 9 | The light ring PCB design is defined in an [Eagle project](eagle) ready for fabrication. It uses [APA102 5050 RGB LEDs](https://www.adafruit.com/product/2343). 10 | 11 | ![PCB](images/pcb.png) 12 | 13 | ## Arduino 14 | 15 | The PCB (5V) is connected to an [Adafruit Feather with BLE](https://www.adafruit.com/product/2829) (3.3V) with a [logic level converter](https://www.adafruit.com/product/757). After [board setup](https://learn.adafruit.com/adafruit-feather-32u4-bluefruit-le/setup) select `Adafruit Feather 32u4` as the board and `USBtinyISP` as the programmer. 16 | 17 | The [Arduino code](arduino/lights.ino) contains the [pin definitions](arduino/lights.ino#L19) and has three library dependencies: 18 | * [APA102](https://github.com/pololu/apa102-arduino#software) 19 | * [Adafruit Bluefruit LE](https://github.com/adafruit/Adafruit_BluefruitLE_nRF51) 20 | * [FastGPIO](https://github.com/pololu/fastgpio-arduino) 21 | 22 | ![PCB](images/ring.jpg) 23 | 24 | ## Android 25 | 26 | The [Android Studio project](android) builds an apk with a background service maintaining the BLE connection. 27 | 28 | Bind to the service from another app and send commands using the [AIDL](android/app/src/main/aidl/net/maxbraun/lights) interface. 29 | 30 | You can also use the [debug UI](android/app/src/main/java/net/maxbraun/lights/DebugActivity.java) or send intents to the [debug service](android/app/src/main/java/net/maxbraun/lights/DebugService.java): 31 | 32 | ``` 33 | adb shell am startservice net.maxbraun.lights/.DebugService 34 | adb shell am broadcast -a net.maxbraun.lights.ALL_WHITE 35 | ``` 36 | -------------------------------------------------------------------------------- /android/app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /android/app/app.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | 11 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | android { 4 | compileSdkVersion 25 5 | defaultConfig { 6 | applicationId "net.maxbraun.lights" 7 | minSdkVersion 23 8 | targetSdkVersion 25 9 | versionCode 1 10 | versionName "1.0" 11 | } 12 | buildTypes { 13 | release { 14 | minifyEnabled false 15 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 16 | } 17 | } 18 | } 19 | 20 | dependencies { 21 | implementation fileTree(dir: 'libs', include: ['*.jar']) 22 | implementation 'com.android.support:appcompat-v7:25.4.0' 23 | } 24 | -------------------------------------------------------------------------------- /android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in /Users/maxbraun/android-sdk/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | 19 | # Uncomment this to preserve the line number information for 20 | # debugging stack traces. 21 | #-keepattributes SourceFile,LineNumberTable 22 | 23 | # If you keep the line number information, uncomment this to 24 | # hide the original source file name. 25 | #-renamesourcefileattribute SourceFile 26 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 17 | 21 | 22 | 23 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /android/app/src/main/aidl/net/maxbraun/lights/ILightsService.aidl: -------------------------------------------------------------------------------- 1 | // ILightsService.aidl 2 | package net.maxbraun.lights; 3 | 4 | import net.maxbraun.lights.ILightsServiceCallback; 5 | 6 | interface ILightsService { 7 | 8 | /** Set all LEDs to white at full brightness. */ 9 | void allWhite(ILightsServiceCallback callback); 10 | 11 | /** Turn all LEDs off. */ 12 | void allOff(ILightsServiceCallback callback); 13 | 14 | /** Set the top one LED to red at full brightness. */ 15 | void oneRed(ILightsServiceCallback callback); 16 | 17 | /** Set the top one LED to blue at full brightness. */ 18 | void oneBlue(ILightsServiceCallback callback); 19 | } 20 | -------------------------------------------------------------------------------- /android/app/src/main/aidl/net/maxbraun/lights/ILightsServiceCallback.aidl: -------------------------------------------------------------------------------- 1 | // ILightsServiceCallback.aidl 2 | package net.maxbraun.lights; 3 | 4 | interface ILightsServiceCallback { 5 | 6 | /** Called when the command has been sent successfully. */ 7 | void onSuccess(); 8 | 9 | /** Called when the command failed to send. */ 10 | void onError(); 11 | } 12 | -------------------------------------------------------------------------------- /android/app/src/main/java/net/maxbraun/lights/DebugActivity.java: -------------------------------------------------------------------------------- 1 | package net.maxbraun.lights; 2 | 3 | import android.content.ComponentName; 4 | import android.content.Intent; 5 | import android.content.ServiceConnection; 6 | import android.os.IBinder; 7 | import android.support.v7.app.AppCompatActivity; 8 | import android.os.Bundle; 9 | import android.util.Log; 10 | import android.view.View; 11 | 12 | public class DebugActivity extends AppCompatActivity { 13 | private static final String TAG = DebugActivity.class.getSimpleName(); 14 | 15 | ILightsService lightsService; 16 | private ServiceConnection lightsServiceConnection = new ServiceConnection() { 17 | @Override 18 | public void onServiceConnected(ComponentName componentName, IBinder service) { 19 | Log.d(TAG, "Lights service connected."); 20 | lightsService = ILightsService.Stub.asInterface(service); 21 | } 22 | 23 | @Override 24 | public void onServiceDisconnected(ComponentName componentName) { 25 | Log.d(TAG, "Lights service disconnected."); 26 | lightsService = null; 27 | } 28 | }; 29 | 30 | @Override 31 | protected void onCreate(Bundle savedInstanceState) { 32 | super.onCreate(savedInstanceState); 33 | setContentView(R.layout.activity_debug); 34 | 35 | bindService(new Intent(this, LightsService.class), lightsServiceConnection, BIND_AUTO_CREATE); 36 | } 37 | 38 | @Override 39 | protected void onDestroy() { 40 | unbindService(lightsServiceConnection); 41 | 42 | super.onDestroy(); 43 | } 44 | 45 | public void allWhite(View view) { 46 | // TODO 47 | } 48 | 49 | public void allOff(View view) { 50 | // TODO 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /android/app/src/main/java/net/maxbraun/lights/DebugService.java: -------------------------------------------------------------------------------- 1 | package net.maxbraun.lights; 2 | 3 | import android.app.Service; 4 | import android.content.BroadcastReceiver; 5 | import android.content.ComponentName; 6 | import android.content.Context; 7 | import android.content.Intent; 8 | import android.content.IntentFilter; 9 | import android.content.ServiceConnection; 10 | import android.os.IBinder; 11 | import android.os.RemoteException; 12 | import android.util.Log; 13 | 14 | public class DebugService extends Service { 15 | private static final String TAG = DebugService.class.getSimpleName(); 16 | 17 | private static final String ACTION_ALL_WHITE = "net.maxbraun.lights.ALL_WHITE"; 18 | private static final String ACTION_ALL_OFF = "net.maxbraun.lights.ALL_OFF"; 19 | private static final String ACTION_ONE_RED = "net.maxbraun.lights.ONE_RED"; 20 | private static final String ACTION_ONE_BLUE = "net.maxbraun.lights.ONE_BLUE"; 21 | 22 | private final BroadcastReceiver debugReceiver = new BroadcastReceiver() { 23 | @Override 24 | public void onReceive(Context context, Intent intent) { 25 | String action = intent.getAction(); 26 | Log.d(TAG, "Received action: " + action); 27 | 28 | if (ACTION_ALL_WHITE.equals(action)) { 29 | allWhite(); 30 | } else if (ACTION_ALL_OFF.equals(action)) { 31 | allOff(); 32 | } else if (ACTION_ONE_RED.equals(action)) { 33 | oneRed(); 34 | } else if (ACTION_ONE_BLUE.equals(action)) { 35 | oneBlue(); 36 | } 37 | } 38 | }; 39 | 40 | ILightsService lightsService; 41 | private ServiceConnection lightsServiceConnection = new ServiceConnection() { 42 | @Override 43 | public void onServiceConnected(ComponentName componentName, IBinder service) { 44 | Log.d(TAG, "Lights service connected."); 45 | lightsService = ILightsService.Stub.asInterface(service); 46 | } 47 | 48 | @Override 49 | public void onServiceDisconnected(ComponentName componentName) { 50 | Log.d(TAG, "Lights service disconnected."); 51 | lightsService = null; 52 | } 53 | }; 54 | 55 | @Override 56 | public void onCreate() { 57 | super.onCreate(); 58 | 59 | bindService(new Intent(this, LightsService.class), lightsServiceConnection, BIND_AUTO_CREATE); 60 | 61 | IntentFilter intentFilter = new IntentFilter(); 62 | intentFilter.addAction(ACTION_ALL_WHITE); 63 | intentFilter.addAction(ACTION_ALL_OFF); 64 | intentFilter.addAction(ACTION_ONE_RED); 65 | intentFilter.addAction(ACTION_ONE_BLUE); 66 | registerReceiver(debugReceiver, intentFilter); 67 | } 68 | 69 | @Override 70 | public void onDestroy() { 71 | unregisterReceiver(debugReceiver); 72 | 73 | super.onDestroy(); 74 | } 75 | 76 | @Override 77 | public IBinder onBind(Intent intent) { 78 | return null; 79 | } 80 | 81 | private void allWhite() { 82 | if (lightsService == null) { 83 | Log.e(TAG, "Lights service not connected."); 84 | return; 85 | } 86 | 87 | try { 88 | lightsService.allWhite(createCallback()); 89 | } catch (RemoteException e) { 90 | Log.e(TAG, "Failed to talk to lights service."); 91 | } 92 | } 93 | 94 | private void allOff() { 95 | if (lightsService == null) { 96 | Log.e(TAG, "Service not connected."); 97 | return; 98 | } 99 | 100 | try { 101 | lightsService.allOff(createCallback()); 102 | } catch (RemoteException e) { 103 | Log.e(TAG, "Failed to talk to lights service."); 104 | } 105 | } 106 | 107 | private void oneRed() { 108 | if (lightsService == null) { 109 | Log.e(TAG, "Service not connected."); 110 | return; 111 | } 112 | 113 | try { 114 | lightsService.oneRed(createCallback()); 115 | } catch (RemoteException e) { 116 | Log.e(TAG, "Failed to talk to lights service."); 117 | } 118 | } 119 | 120 | private void oneBlue() { 121 | if (lightsService == null) { 122 | Log.e(TAG, "Service not connected."); 123 | return; 124 | } 125 | 126 | try { 127 | lightsService.oneBlue(createCallback()); 128 | } catch (RemoteException e) { 129 | Log.e(TAG, "Failed to talk to lights service."); 130 | } 131 | } 132 | 133 | private ILightsServiceCallback createCallback() { 134 | return new ILightsServiceCallback.Stub() { 135 | @Override 136 | public void onSuccess() throws RemoteException { 137 | Log.d(TAG, "Sent command."); 138 | } 139 | 140 | @Override 141 | public void onError() throws RemoteException { 142 | Log.d(TAG, "Failed to send command."); 143 | } 144 | }; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /android/app/src/main/java/net/maxbraun/lights/LightsService.java: -------------------------------------------------------------------------------- 1 | package net.maxbraun.lights; 2 | 3 | import android.Manifest; 4 | import android.app.Service; 5 | import android.bluetooth.BluetoothAdapter; 6 | import android.bluetooth.BluetoothDevice; 7 | import android.bluetooth.BluetoothGatt; 8 | import android.bluetooth.BluetoothGattCallback; 9 | import android.bluetooth.BluetoothGattCharacteristic; 10 | import android.bluetooth.BluetoothGattService; 11 | import android.bluetooth.BluetoothManager; 12 | import android.bluetooth.BluetoothProfile; 13 | import android.bluetooth.le.BluetoothLeScanner; 14 | import android.bluetooth.le.ScanCallback; 15 | import android.bluetooth.le.ScanFilter; 16 | import android.bluetooth.le.ScanResult; 17 | import android.bluetooth.le.ScanSettings; 18 | import android.content.Context; 19 | import android.content.Intent; 20 | import android.content.pm.PackageManager; 21 | import android.os.Handler; 22 | import android.os.IBinder; 23 | import android.os.Looper; 24 | import android.os.RemoteException; 25 | import android.support.v4.content.ContextCompat; 26 | import android.util.Log; 27 | 28 | import java.util.Arrays; 29 | import java.util.UUID; 30 | import java.util.concurrent.TimeUnit; 31 | 32 | public class LightsService extends Service { 33 | private static final String TAG = LightsService.class.getSimpleName(); 34 | 35 | private static final long TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(10); 36 | private static final long RETRY_DELAY_MILLIS = TimeUnit.SECONDS.toMillis(1); 37 | 38 | private static final String FEATHER_ADDRESS = "FA:04:9E:15:B9:BA"; 39 | 40 | private static final UUID UART_SERVICE_UUID = 41 | UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9e"); 42 | 43 | private static final UUID TX_CHARACTERISTIC_UUID = 44 | UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9e"); 45 | 46 | private static final String ALL_WHITE_COMMAND = "AW"; 47 | private static final String ALL_OFF_COMMAND = "A0"; 48 | private static final String ONE_RED_COMMAND = "1R"; 49 | private static final String ONE_BLUE_COMMAND = "1B"; 50 | 51 | private static final ScanFilter scanFilter = new ScanFilter.Builder() 52 | .setDeviceAddress(FEATHER_ADDRESS) 53 | .build(); 54 | 55 | private static final ScanSettings scanSettings = new ScanSettings.Builder() 56 | .setCallbackType(ScanSettings.CALLBACK_TYPE_FIRST_MATCH) 57 | .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) 58 | .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) 59 | .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) 60 | .setReportDelay(0) 61 | .build(); 62 | 63 | private final ScanCallback scanCallback = new ScanCallback() { 64 | @Override 65 | public void onScanResult(int callbackType, ScanResult result) { 66 | Log.v(TAG, "Scan successful: " + result); 67 | 68 | assertState(State.SCANNING, 69 | State.READY /* on reconnect */, 70 | State.CONNECTING /* on reconnect */); 71 | 72 | device = result.getDevice(); 73 | 74 | state = State.DISCONNECTED; 75 | connect(); 76 | } 77 | 78 | @Override 79 | public void onScanFailed(int errorCode) { 80 | Log.e(TAG, "Scan failed: " + errorCode); 81 | 82 | assertState(State.SCANNING); 83 | state = State.INITIALIZED; 84 | 85 | retryFromHere(); 86 | } 87 | }; 88 | 89 | private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() { 90 | @Override 91 | public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { 92 | Log.v(TAG, String.format("New connection state: %d (status %d)", newState, status)); 93 | 94 | if (newState == BluetoothProfile.STATE_CONNECTED) { 95 | if (status == BluetoothGatt.GATT_SUCCESS) { 96 | Log.d(TAG, "Connected."); 97 | state = State.CONNECTED; 98 | discover(); 99 | } else { 100 | Log.w(TAG, "Failed to connect."); 101 | retryFromHere(); 102 | } 103 | } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { 104 | if (status == BluetoothGatt.GATT_SUCCESS) { 105 | Log.d(TAG, "Disconnected."); 106 | state = State.DISCONNECTED; 107 | connect(); 108 | } 109 | } 110 | } 111 | 112 | @Override 113 | public void onServicesDiscovered(BluetoothGatt gatt, int status) { 114 | if (status == BluetoothGatt.GATT_SUCCESS) { 115 | Log.d(TAG, "Services discovered."); 116 | state = State.READY; 117 | } else { 118 | Log.d(TAG, "Failed to discover services."); 119 | retryFromHere(); 120 | } 121 | } 122 | }; 123 | 124 | private final ILightsService.Stub binder = new ILightsService.Stub() { 125 | @Override 126 | public void allWhite(ILightsServiceCallback callback) throws RemoteException { 127 | if (send(ALL_WHITE_COMMAND)) { 128 | callback.onSuccess(); 129 | } else { 130 | callback.onError(); 131 | } 132 | } 133 | 134 | @Override 135 | public void allOff(ILightsServiceCallback callback) throws RemoteException { 136 | if (send(ALL_OFF_COMMAND)) { 137 | callback.onSuccess(); 138 | } else { 139 | callback.onError(); 140 | } 141 | } 142 | 143 | @Override 144 | public void oneRed(ILightsServiceCallback callback) throws RemoteException { 145 | if (send(ONE_RED_COMMAND)) { 146 | callback.onSuccess(); 147 | } else { 148 | callback.onError(); 149 | } 150 | } 151 | 152 | @Override 153 | public void oneBlue(ILightsServiceCallback callback) throws RemoteException { 154 | if (send(ONE_BLUE_COMMAND)) { 155 | callback.onSuccess(); 156 | } else { 157 | callback.onError(); 158 | } 159 | } 160 | }; 161 | 162 | private final Handler handler = new Handler(Looper.getMainLooper()); 163 | 164 | private enum State { 165 | INITIALIZED, 166 | SCANNING, 167 | DISCONNECTED, 168 | CONNECTING, 169 | CONNECTED, 170 | DISCOVERING, 171 | READY, 172 | } 173 | 174 | private State state; 175 | 176 | private BluetoothLeScanner scanner; 177 | private BluetoothDevice device; 178 | private BluetoothGatt gatt; 179 | 180 | @Override 181 | public void onCreate() { 182 | Log.v(TAG, "Service created."); 183 | super.onCreate(); 184 | 185 | BluetoothManager bluetoothManager = 186 | (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE); 187 | BluetoothAdapter bluetoothAdapter = bluetoothManager.getAdapter(); 188 | 189 | if ((bluetoothAdapter == null) || !bluetoothAdapter.isEnabled()) { 190 | throw new IllegalStateException("Bluetooth is not enabled."); 191 | } 192 | 193 | if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) 194 | != PackageManager.PERMISSION_GRANTED) { 195 | throw new IllegalStateException("Location permission has not been granted."); 196 | } 197 | 198 | scanner = bluetoothAdapter.getBluetoothLeScanner(); 199 | 200 | state = State.INITIALIZED; 201 | scan(); 202 | } 203 | 204 | @Override 205 | public void onDestroy() { 206 | Log.v(TAG, "Service destroyed."); 207 | close(); 208 | 209 | super.onDestroy(); 210 | } 211 | 212 | @Override 213 | public IBinder onBind(Intent intent) { 214 | return binder; 215 | } 216 | 217 | private void scan() { 218 | assertState(State.INITIALIZED); 219 | 220 | Log.d(TAG, "Starting scan."); 221 | state = State.SCANNING; 222 | 223 | scanner.startScan(Arrays.asList(scanFilter), scanSettings, scanCallback); 224 | 225 | startTimeout(State.INITIALIZED, new Runnable() { 226 | @Override 227 | public void run() { 228 | scanner.stopScan(scanCallback); 229 | } 230 | }); 231 | } 232 | 233 | private void connect() { 234 | assertState(State.DISCONNECTED); 235 | 236 | Log.d(TAG, "Connecting to device: " + device); 237 | state = State.CONNECTING; 238 | 239 | gatt = device.connectGatt(this, true, gattCallback); 240 | 241 | startTimeout(State.DISCONNECTED, null); 242 | } 243 | 244 | private void close() { 245 | Log.d(TAG, "Closing."); 246 | 247 | scanner.stopScan(scanCallback); 248 | 249 | if (gatt != null) { 250 | gatt.close(); 251 | gatt = null; 252 | } 253 | 254 | state = State.INITIALIZED; 255 | } 256 | 257 | private void discover() { 258 | assertState(State.CONNECTED); 259 | 260 | if (gatt.discoverServices()) { 261 | Log.d(TAG, "Discovering."); 262 | state = State.DISCOVERING; 263 | } else { 264 | Log.e(TAG, "Failed to start discovery."); 265 | retryFromHere(); 266 | } 267 | } 268 | 269 | private boolean send(String command) { 270 | if (state != State.READY) { 271 | retryFromHere(); 272 | return false; 273 | } 274 | 275 | Log.d(TAG, "Sending command: " + command); 276 | 277 | BluetoothGattService gattService = gatt.getService(UART_SERVICE_UUID); 278 | BluetoothGattCharacteristic txCharacteristic = 279 | gattService.getCharacteristic(TX_CHARACTERISTIC_UUID); 280 | 281 | txCharacteristic.setValue(command); 282 | gatt.writeCharacteristic(txCharacteristic); 283 | 284 | return true; 285 | } 286 | 287 | private void assertState(State... expected) { 288 | if (!Arrays.asList(expected).contains(state)) { 289 | throw new IllegalStateException(String.format("Expected %s, but was %s.", 290 | Arrays.toString(expected), state)); 291 | } 292 | } 293 | 294 | private void startTimeout(final State failState, final Runnable cancel) { 295 | final State startState = state; 296 | handler.postDelayed(new Runnable() { 297 | @Override 298 | public void run() { 299 | if (state == startState) { 300 | Log.w(TAG, String.format("Timed out after %d ms in %s.", TIMEOUT_MILLIS, startState)); 301 | if (cancel != null) { 302 | cancel.run(); 303 | } 304 | state = failState; 305 | retryFromHere(); 306 | } 307 | } 308 | }, TIMEOUT_MILLIS); 309 | } 310 | 311 | private void retryFromHere() { 312 | Log.d(TAG, String.format("Retrying from %s in %d ms.", state, RETRY_DELAY_MILLIS)); 313 | 314 | handler.postDelayed(new Runnable() { 315 | @Override 316 | public void run() { 317 | switch (state) { 318 | case INITIALIZED: 319 | scan(); 320 | break; 321 | case SCANNING: 322 | // Wait. 323 | break; 324 | case DISCONNECTED: 325 | connect(); 326 | break; 327 | case CONNECTING: 328 | // Wait. 329 | break; 330 | case CONNECTED: 331 | discover(); 332 | break; 333 | case DISCOVERING: 334 | // Wait. 335 | break; 336 | case READY: 337 | // Done. 338 | break; 339 | default: 340 | throw new IllegalArgumentException("Not handling state: " + state); 341 | } 342 | } 343 | }, RETRY_DELAY_MILLIS); 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /android/app/src/main/java/net/maxbraun/lights/RestartReceiver.java: -------------------------------------------------------------------------------- 1 | package net.maxbraun.lights; 2 | 3 | import android.content.BroadcastReceiver; 4 | import android.content.Context; 5 | import android.content.Intent; 6 | 7 | public class RestartReceiver extends BroadcastReceiver { 8 | 9 | @Override 10 | public void onReceive(Context context, Intent intent) { 11 | context.startService(new Intent(context, LightsService.class)); 12 | context.startService(new Intent(context, DebugService.class)); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /android/app/src/main/res/layout/activity_debug.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 |