| package org.libsdl.app; |
| |
| import android.content.Context; |
| import android.bluetooth.BluetoothDevice; |
| import android.bluetooth.BluetoothGatt; |
| import android.bluetooth.BluetoothGattCallback; |
| import android.bluetooth.BluetoothGattCharacteristic; |
| import android.bluetooth.BluetoothGattDescriptor; |
| import android.bluetooth.BluetoothManager; |
| import android.bluetooth.BluetoothProfile; |
| import android.bluetooth.BluetoothGattService; |
| import android.os.Handler; |
| import android.os.Looper; |
| import android.util.Log; |
| import android.os.*; |
| |
| //import com.android.internal.util.HexDump; |
| |
| import java.lang.Runnable; |
| import java.util.Arrays; |
| import java.util.LinkedList; |
| import java.util.UUID; |
| |
| class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice { |
| |
| private static final String TAG = "hidapi"; |
| private HIDDeviceManager mManager; |
| private BluetoothDevice mDevice; |
| private int mDeviceId; |
| private BluetoothGatt mGatt; |
| private boolean mIsRegistered = false; |
| private boolean mIsConnected = false; |
| private boolean mIsChromebook = false; |
| private boolean mIsReconnecting = false; |
| private boolean mFrozen = false; |
| private LinkedList<GattOperation> mOperations; |
| GattOperation mCurrentOperation = null; |
| private Handler mHandler; |
| |
| private static final int TRANSPORT_AUTO = 0; |
| private static final int TRANSPORT_BREDR = 1; |
| private static final int TRANSPORT_LE = 2; |
| |
| private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000; |
| |
| static public final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3"); |
| static public final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3"); |
| static public final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3"); |
| static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 }; |
| |
| static class GattOperation { |
| private enum Operation { |
| CHR_READ, |
| CHR_WRITE, |
| ENABLE_NOTIFICATION |
| } |
| |
| Operation mOp; |
| UUID mUuid; |
| byte[] mValue; |
| BluetoothGatt mGatt; |
| boolean mResult = true; |
| |
| private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) { |
| mGatt = gatt; |
| mOp = operation; |
| mUuid = uuid; |
| } |
| |
| private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) { |
| mGatt = gatt; |
| mOp = operation; |
| mUuid = uuid; |
| mValue = value; |
| } |
| |
| public void run() { |
| // This is executed in main thread |
| BluetoothGattCharacteristic chr; |
| |
| switch (mOp) { |
| case CHR_READ: |
| chr = getCharacteristic(mUuid); |
| //Log.v(TAG, "Reading characteristic " + chr.getUuid()); |
| if (!mGatt.readCharacteristic(chr)) { |
| Log.e(TAG, "Unable to read characteristic " + mUuid.toString()); |
| mResult = false; |
| break; |
| } |
| mResult = true; |
| break; |
| case CHR_WRITE: |
| chr = getCharacteristic(mUuid); |
| //Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value)); |
| chr.setValue(mValue); |
| if (!mGatt.writeCharacteristic(chr)) { |
| Log.e(TAG, "Unable to write characteristic " + mUuid.toString()); |
| mResult = false; |
| break; |
| } |
| mResult = true; |
| break; |
| case ENABLE_NOTIFICATION: |
| chr = getCharacteristic(mUuid); |
| //Log.v(TAG, "Writing descriptor of " + chr.getUuid()); |
| if (chr != null) { |
| BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); |
| if (cccd != null) { |
| int properties = chr.getProperties(); |
| byte[] value; |
| if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) { |
| value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE; |
| } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) { |
| value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE; |
| } else { |
| Log.e(TAG, "Unable to start notifications on input characteristic"); |
| mResult = false; |
| return; |
| } |
| |
| mGatt.setCharacteristicNotification(chr, true); |
| cccd.setValue(value); |
| if (!mGatt.writeDescriptor(cccd)) { |
| Log.e(TAG, "Unable to write descriptor " + mUuid.toString()); |
| mResult = false; |
| return; |
| } |
| mResult = true; |
| } |
| } |
| } |
| } |
| |
| public boolean finish() { |
| return mResult; |
| } |
| |
| private BluetoothGattCharacteristic getCharacteristic(UUID uuid) { |
| BluetoothGattService valveService = mGatt.getService(steamControllerService); |
| if (valveService == null) |
| return null; |
| return valveService.getCharacteristic(uuid); |
| } |
| |
| static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) { |
| return new GattOperation(gatt, Operation.CHR_READ, uuid); |
| } |
| |
| static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) { |
| return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value); |
| } |
| |
| static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) { |
| return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid); |
| } |
| } |
| |
| public HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) { |
| mManager = manager; |
| mDevice = device; |
| mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier()); |
| mIsRegistered = false; |
| mIsChromebook = mManager.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); |
| mOperations = new LinkedList<GattOperation>(); |
| mHandler = new Handler(Looper.getMainLooper()); |
| |
| mGatt = connectGatt(); |
| final HIDDeviceBLESteamController finalThis = this; |
| mHandler.postDelayed(new Runnable() { |
| @Override |
| public void run() { |
| finalThis.checkConnectionForChromebookIssue(); |
| } |
| }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); |
| } |
| |
| public String getIdentifier() { |
| return String.format("SteamController.%s", mDevice.getAddress()); |
| } |
| |
| public BluetoothGatt getGatt() { |
| return mGatt; |
| } |
| |
| // Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead |
| // of TRANSPORT_LE. Let's force ourselves to connect low energy. |
| private BluetoothGatt connectGatt(boolean managed) { |
| if (Build.VERSION.SDK_INT >= 23) { |
| try { |
| return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE); |
| } catch (Exception e) { |
| return mDevice.connectGatt(mManager.getContext(), managed, this); |
| } |
| } else { |
| return mDevice.connectGatt(mManager.getContext(), managed, this); |
| } |
| } |
| |
| private BluetoothGatt connectGatt() { |
| return connectGatt(false); |
| } |
| |
| protected int getConnectionState() { |
| |
| Context context = mManager.getContext(); |
| if (context == null) { |
| // We are lacking any context to get our Bluetooth information. We'll just assume disconnected. |
| return BluetoothProfile.STATE_DISCONNECTED; |
| } |
| |
| BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE); |
| if (btManager == null) { |
| // This device doesn't support Bluetooth. We should never be here, because how did |
| // we instantiate a device to start with? |
| return BluetoothProfile.STATE_DISCONNECTED; |
| } |
| |
| return btManager.getConnectionState(mDevice, BluetoothProfile.GATT); |
| } |
| |
| public void reconnect() { |
| |
| if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) { |
| mGatt.disconnect(); |
| mGatt = connectGatt(); |
| } |
| |
| } |
| |
| protected void checkConnectionForChromebookIssue() { |
| if (!mIsChromebook) { |
| // We only do this on Chromebooks, because otherwise it's really annoying to just attempt |
| // over and over. |
| return; |
| } |
| |
| int connectionState = getConnectionState(); |
| |
| switch (connectionState) { |
| case BluetoothProfile.STATE_CONNECTED: |
| if (!mIsConnected) { |
| // We are in the Bad Chromebook Place. We can force a disconnect |
| // to try to recover. |
| Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect."); |
| mIsReconnecting = true; |
| mGatt.disconnect(); |
| mGatt = connectGatt(false); |
| break; |
| } |
| else if (!isRegistered()) { |
| if (mGatt.getServices().size() > 0) { |
| Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration. Trying to recover."); |
| probeService(this); |
| } |
| else { |
| Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover."); |
| mIsReconnecting = true; |
| mGatt.disconnect(); |
| mGatt = connectGatt(false); |
| break; |
| } |
| } |
| else { |
| Log.v(TAG, "Chromebook: We are connected, and registered. Everything's good!"); |
| return; |
| } |
| break; |
| |
| case BluetoothProfile.STATE_DISCONNECTED: |
| Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover."); |
| |
| mIsReconnecting = true; |
| mGatt.disconnect(); |
| mGatt = connectGatt(false); |
| break; |
| |
| case BluetoothProfile.STATE_CONNECTING: |
| Log.v(TAG, "Chromebook: We're still trying to connect. Waiting a bit longer."); |
| break; |
| } |
| |
| final HIDDeviceBLESteamController finalThis = this; |
| mHandler.postDelayed(new Runnable() { |
| @Override |
| public void run() { |
| finalThis.checkConnectionForChromebookIssue(); |
| } |
| }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); |
| } |
| |
| private boolean isRegistered() { |
| return mIsRegistered; |
| } |
| |
| private void setRegistered() { |
| mIsRegistered = true; |
| } |
| |
| private boolean probeService(HIDDeviceBLESteamController controller) { |
| |
| if (isRegistered()) { |
| return true; |
| } |
| |
| if (!mIsConnected) { |
| return false; |
| } |
| |
| Log.v(TAG, "probeService controller=" + controller); |
| |
| for (BluetoothGattService service : mGatt.getServices()) { |
| if (service.getUuid().equals(steamControllerService)) { |
| Log.v(TAG, "Found Valve steam controller service " + service.getUuid()); |
| |
| for (BluetoothGattCharacteristic chr : service.getCharacteristics()) { |
| if (chr.getUuid().equals(inputCharacteristic)) { |
| Log.v(TAG, "Found input characteristic"); |
| // Start notifications |
| BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); |
| if (cccd != null) { |
| enableNotification(chr.getUuid()); |
| } |
| } |
| } |
| return true; |
| } |
| } |
| |
| if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) { |
| Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us."); |
| mIsConnected = false; |
| mIsReconnecting = true; |
| mGatt.disconnect(); |
| mGatt = connectGatt(false); |
| } |
| |
| return false; |
| } |
| |
| ////////////////////////////////////////////////////////////////////////////////////////////////////// |
| ////////////////////////////////////////////////////////////////////////////////////////////////////// |
| ////////////////////////////////////////////////////////////////////////////////////////////////////// |
| |
| private void finishCurrentGattOperation() { |
| GattOperation op = null; |
| synchronized (mOperations) { |
| if (mCurrentOperation != null) { |
| op = mCurrentOperation; |
| mCurrentOperation = null; |
| } |
| } |
| if (op != null) { |
| boolean result = op.finish(); // TODO: Maybe in main thread as well? |
| |
| // Our operation failed, let's add it back to the beginning of our queue. |
| if (!result) { |
| mOperations.addFirst(op); |
| } |
| } |
| executeNextGattOperation(); |
| } |
| |
| private void executeNextGattOperation() { |
| synchronized (mOperations) { |
| if (mCurrentOperation != null) |
| return; |
| |
| if (mOperations.isEmpty()) |
| return; |
| |
| mCurrentOperation = mOperations.removeFirst(); |
| } |
| |
| // Run in main thread |
| mHandler.post(new Runnable() { |
| @Override |
| public void run() { |
| synchronized (mOperations) { |
| if (mCurrentOperation == null) { |
| Log.e(TAG, "Current operation null in executor?"); |
| return; |
| } |
| |
| mCurrentOperation.run(); |
| // now wait for the GATT callback and when it comes, finish this operation |
| } |
| } |
| }); |
| } |
| |
| private void queueGattOperation(GattOperation op) { |
| synchronized (mOperations) { |
| mOperations.add(op); |
| } |
| executeNextGattOperation(); |
| } |
| |
| private void enableNotification(UUID chrUuid) { |
| GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid); |
| queueGattOperation(op); |
| } |
| |
| public void writeCharacteristic(UUID uuid, byte[] value) { |
| GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value); |
| queueGattOperation(op); |
| } |
| |
| public void readCharacteristic(UUID uuid) { |
| GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid); |
| queueGattOperation(op); |
| } |
| |
| ////////////////////////////////////////////////////////////////////////////////////////////////////// |
| ////////////// BluetoothGattCallback overridden methods |
| ////////////////////////////////////////////////////////////////////////////////////////////////////// |
| |
| public void onConnectionStateChange(BluetoothGatt g, int status, int newState) { |
| //Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState); |
| mIsReconnecting = false; |
| if (newState == 2) { |
| mIsConnected = true; |
| // Run directly, without GattOperation |
| if (!isRegistered()) { |
| mHandler.post(new Runnable() { |
| @Override |
| public void run() { |
| mGatt.discoverServices(); |
| } |
| }); |
| } |
| } |
| else if (newState == 0) { |
| mIsConnected = false; |
| } |
| |
| // Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent. |
| } |
| |
| public void onServicesDiscovered(BluetoothGatt gatt, int status) { |
| //Log.v(TAG, "onServicesDiscovered status=" + status); |
| if (status == 0) { |
| if (gatt.getServices().size() == 0) { |
| Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack."); |
| mIsReconnecting = true; |
| mIsConnected = false; |
| gatt.disconnect(); |
| mGatt = connectGatt(false); |
| } |
| else { |
| probeService(this); |
| } |
| } |
| } |
| |
| public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { |
| //Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid()); |
| |
| if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) { |
| mManager.HIDDeviceFeatureReport(getId(), characteristic.getValue()); |
| } |
| |
| finishCurrentGattOperation(); |
| } |
| |
| public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { |
| //Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid()); |
| |
| if (characteristic.getUuid().equals(reportCharacteristic)) { |
| // Only register controller with the native side once it has been fully configured |
| if (!isRegistered()) { |
| Log.v(TAG, "Registering Steam Controller with ID: " + getId()); |
| mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0); |
| setRegistered(); |
| } |
| } |
| |
| finishCurrentGattOperation(); |
| } |
| |
| public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { |
| // Enable this for verbose logging of controller input reports |
| //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue())); |
| |
| if (characteristic.getUuid().equals(inputCharacteristic) && !mFrozen) { |
| mManager.HIDDeviceInputReport(getId(), characteristic.getValue()); |
| } |
| } |
| |
| public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { |
| //Log.v(TAG, "onDescriptorRead status=" + status); |
| } |
| |
| public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { |
| BluetoothGattCharacteristic chr = descriptor.getCharacteristic(); |
| //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid()); |
| |
| if (chr.getUuid().equals(inputCharacteristic)) { |
| boolean hasWrittenInputDescriptor = true; |
| BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic); |
| if (reportChr != null) { |
| Log.v(TAG, "Writing report characteristic to enter valve mode"); |
| reportChr.setValue(enterValveMode); |
| gatt.writeCharacteristic(reportChr); |
| } |
| } |
| |
| finishCurrentGattOperation(); |
| } |
| |
| public void onReliableWriteCompleted(BluetoothGatt gatt, int status) { |
| //Log.v(TAG, "onReliableWriteCompleted status=" + status); |
| } |
| |
| public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) { |
| //Log.v(TAG, "onReadRemoteRssi status=" + status); |
| } |
| |
| public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { |
| //Log.v(TAG, "onMtuChanged status=" + status); |
| } |
| |
| ////////////////////////////////////////////////////////////////////////////////////////////////////// |
| //////// Public API |
| ////////////////////////////////////////////////////////////////////////////////////////////////////// |
| |
| @Override |
| public int getId() { |
| return mDeviceId; |
| } |
| |
| @Override |
| public int getVendorId() { |
| // Valve Corporation |
| final int VALVE_USB_VID = 0x28DE; |
| return VALVE_USB_VID; |
| } |
| |
| @Override |
| public int getProductId() { |
| // We don't have an easy way to query from the Bluetooth device, but we know what it is |
| final int D0G_BLE2_PID = 0x1106; |
| return D0G_BLE2_PID; |
| } |
| |
| @Override |
| public String getSerialNumber() { |
| // This will be read later via feature report by Steam |
| return "12345"; |
| } |
| |
| @Override |
| public int getVersion() { |
| return 0; |
| } |
| |
| @Override |
| public String getManufacturerName() { |
| return "Valve Corporation"; |
| } |
| |
| @Override |
| public String getProductName() { |
| return "Steam Controller"; |
| } |
| |
| @Override |
| public boolean open() { |
| return true; |
| } |
| |
| @Override |
| public int sendFeatureReport(byte[] report) { |
| if (!isRegistered()) { |
| Log.e(TAG, "Attempted sendFeatureReport before Steam Controller is registered!"); |
| if (mIsConnected) { |
| probeService(this); |
| } |
| return -1; |
| } |
| |
| // We need to skip the first byte, as that doesn't go over the air |
| byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1); |
| //Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(actual_report)); |
| writeCharacteristic(reportCharacteristic, actual_report); |
| return report.length; |
| } |
| |
| @Override |
| public int sendOutputReport(byte[] report) { |
| if (!isRegistered()) { |
| Log.e(TAG, "Attempted sendOutputReport before Steam Controller is registered!"); |
| if (mIsConnected) { |
| probeService(this); |
| } |
| return -1; |
| } |
| |
| //Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(report)); |
| writeCharacteristic(reportCharacteristic, report); |
| return report.length; |
| } |
| |
| @Override |
| public boolean getFeatureReport(byte[] report) { |
| if (!isRegistered()) { |
| Log.e(TAG, "Attempted getFeatureReport before Steam Controller is registered!"); |
| if (mIsConnected) { |
| probeService(this); |
| } |
| return false; |
| } |
| |
| //Log.v(TAG, "getFeatureReport"); |
| readCharacteristic(reportCharacteristic); |
| return true; |
| } |
| |
| @Override |
| public void close() { |
| } |
| |
| @Override |
| public void setFrozen(boolean frozen) { |
| mFrozen = frozen; |
| } |
| |
| @Override |
| public void shutdown() { |
| close(); |
| |
| BluetoothGatt g = mGatt; |
| if (g != null) { |
| g.disconnect(); |
| g.close(); |
| mGatt = null; |
| } |
| mManager = null; |
| mIsRegistered = false; |
| mIsConnected = false; |
| mOperations.clear(); |
| } |
| |
| } |
| |