From 76aae74ae23ff5bb2ff8ee7258ec4e183cd0fb1c Mon Sep 17 00:00:00 2001 From: Philipp Kapfer Date: Fri, 27 Feb 2026 10:10:18 +0100 Subject: [PATCH 1/6] Added Bluetooth driver for Steam Controller --- app/src/main/AndroidManifest.xml | 8 + app/src/main/java/com/limelight/Game.java | 26 +- .../binding/input/ControllerHandler.java | 4 +- .../input/driver/AbstractController.java | 4 +- .../input/driver/AbstractXboxController.java | 2 +- .../input/driver/BluetoothDriverService.java | 234 +++++ ...ner.java => ControllerDriverListener.java} | 24 +- .../input/driver/ProConController.java | 2 +- .../binding/input/driver/SteamController.java | 823 ++++++++++++++++++ .../input/driver/UsbDriverService.java | 23 +- .../input/driver/Xbox360Controller.java | 2 +- .../input/driver/Xbox360WirelessDongle.java | 5 +- .../input/driver/XboxOneController.java | 2 +- .../preferences/PreferenceConfiguration.java | 5 +- 14 files changed, 1129 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/com/limelight/binding/input/driver/BluetoothDriverService.java rename app/src/main/java/com/limelight/binding/input/driver/{UsbDriverListener.java => ControllerDriverListener.java} (91%) mode change 100755 => 100644 create mode 100644 app/src/main/java/com/limelight/binding/input/driver/SteamController.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6fa6177775..3d5ffce6f9 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,8 @@ + + + + mBluetoothDevices = new HashMap<>(); + private boolean started; + private boolean mIsChromebook; + private Handler mHandler; + private BluetoothManager mBluetoothManager; + private List mLastBluetoothDevices; + private final BroadcastReceiver mBluetoothBroadcast = new BluetoothEventReceiver(); + private final BluetoothDriverBinder binder = new BluetoothDriverBinder(); + + private ControllerDriverListener listener; + + @Override + public void onCreate() { + super.onCreate(); + mIsChromebook = getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + } + + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + private void initializeBluetooth() { + //Log.d(TAG, "Initializing Bluetooth"); + + if (started) { + return; + } + started = true; + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R && + getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH, getPackageName()) != PackageManager.PERMISSION_GRANTED) { + LimeLog.warning("Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH"); + return; + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH_CONNECT, getPackageName()) != PackageManager.PERMISSION_GRANTED) { + LimeLog.warning("Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH_CONNECT"); + return; + } + + if (!getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { + LimeLog.warning("Couldn't initialize Bluetooth, this version of Android does not support Bluetooth LE"); + return; + } + + // Find bonded bluetooth controllers and create SteamControllers for them + mBluetoothManager = (BluetoothManager)getSystemService(Context.BLUETOOTH_SERVICE); + if (mBluetoothManager == null) { + // This device doesn't support Bluetooth. + return; + } + + BluetoothAdapter btAdapter = mBluetoothManager.getAdapter(); + if (btAdapter == null) { + // This device has Bluetooth support in the codebase, but has no available adapters. + return; + } + + // Get our bonded devices. + for (BluetoothDevice device : btAdapter.getBondedDevices()) { + LimeLog.info("Bluetooth device available: " + device); + if (isSteamController(device)) { + connectBluetoothDevice(device); + } + + } + + // NOTE: These don't work on Chromebooks, to my undying dismay. + IntentFilter filter = new IntentFilter(); + filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED); + filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED); + if (Build.VERSION.SDK_INT >= 33) { /* Android 13.0 (TIRAMISU) */ + registerReceiver(mBluetoothBroadcast, filter, Context.RECEIVER_EXPORTED); + } else { + registerReceiver(mBluetoothBroadcast, filter); + } + + if (mIsChromebook) { + mHandler = new Handler(Looper.getMainLooper()); + mLastBluetoothDevices = new ArrayList<>(); + + // final HIDDeviceManager finalThis = this; + // mHandler.postDelayed(new Runnable() { + // @Override + // public void run() { + // finalThis.chromebookConnectionHandler(); + // } + // }, 5000); + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private void shutdownBluetooth() { + if (!started) { + return; + } + started = false; + + // Stop the attachment receiver + unregisterReceiver(mBluetoothBroadcast); + + // Stop all controllers + while (!mBluetoothDevices.isEmpty()) { + // Stop and remove the controller + disconnectBluetoothDevice(mBluetoothDevices.keySet().iterator().next()); + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void connectBluetoothDevice(BluetoothDevice bluetoothDevice) { + synchronized (this) { + if (mBluetoothDevices.containsKey(bluetoothDevice)) { + LimeLog.info("Steam controller with address " + bluetoothDevice + " already exists, attempting reconnect"); + + SteamController device = mBluetoothDevices.get(bluetoothDevice); + device.reconnect(); + } + SteamController device = new SteamController(listener, this, UsbDriverService.getNextDeviceId(), bluetoothDevice); + mBluetoothDevices.put(bluetoothDevice, device); + device.start(); + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void disconnectBluetoothDevice(BluetoothDevice bluetoothDevice) { + synchronized (this) { + SteamController device = mBluetoothDevices.get(bluetoothDevice); + if (device == null) + return; + + device.stop(); + mBluetoothDevices.remove(bluetoothDevice); + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public boolean isSteamController(BluetoothDevice bluetoothDevice) { + // Sanity check. If you pass in a null device, by definition it is never a Steam Controller. + if (bluetoothDevice == null) { + return false; + } + + // If the device has no local name, we really don't want to try an equality check against it. + if (bluetoothDevice.getName() == null) { + return false; + } + + // Steam Controllers will always support Bluetooth Low Energy + if ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) == 0) { + return false; + } + + // Match on the name either the original Steam Controller or the new second-generation one advertise with. + return bluetoothDevice.getName().equals("SteamController") || bluetoothDevice.getName().startsWith("Steam Ctrl"); + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void onDestroy() { + shutdownBluetooth(); + } + + public class BluetoothDriverBinder extends Binder { + public void start() { + BluetoothDriverService.this.initializeBluetooth(); + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void stop() { + BluetoothDriverService.this.shutdownBluetooth(); + } + + public void setListener(ControllerDriverListener listener) { + BluetoothDriverService.this.listener = listener; + + // Report all controllerMap that already exist + if (listener != null) { + for (AbstractController controller : mBluetoothDevices.values()) { + listener.deviceAdded(controller); + } + } + } + } + + public class BluetoothEventReceiver extends BroadcastReceiver { + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + // Bluetooth device was connected. If it was a Steam Controller, handle it + if (BluetoothDevice.ACTION_ACL_CONNECTED.equals(action)) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Log.d(TAG, "Bluetooth device connected: " + device); + + if (isSteamController(device)) { + connectBluetoothDevice(device); + } + } + + // Bluetooth device was disconnected, remove from controller manager (if any) + if (BluetoothDevice.ACTION_ACL_DISCONNECTED.equals(action)) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Log.d(TAG, "Bluetooth device disconnected: " + device); + + disconnectBluetoothDevice(device); + } + } + } +} diff --git a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.java b/app/src/main/java/com/limelight/binding/input/driver/ControllerDriverListener.java old mode 100755 new mode 100644 similarity index 91% rename from app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.java rename to app/src/main/java/com/limelight/binding/input/driver/ControllerDriverListener.java index 483d5cdfa9..789bf04eae --- a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.java +++ b/app/src/main/java/com/limelight/binding/input/driver/ControllerDriverListener.java @@ -1,12 +1,12 @@ -package com.limelight.binding.input.driver; - -public interface UsbDriverListener { - void reportControllerState(int controllerId, int buttonFlags, - float leftStickX, float leftStickY, - float rightStickX, float rightStickY, - float leftTrigger, float rightTrigger); - void reportControllerMotion(int controllerId, byte motionType, float motionX, float motionY, float motionZ); - - void deviceRemoved(AbstractController controller); - void deviceAdded(AbstractController controller); -} +package com.limelight.binding.input.driver; + +public interface ControllerDriverListener { + void reportControllerState(int controllerId, int buttonFlags, + float leftStickX, float leftStickY, + float rightStickX, float rightStickY, + float leftTrigger, float rightTrigger); + void reportControllerMotion(int controllerId, byte motionType, float motionX, float motionY, float motionZ); + + void deviceRemoved(AbstractController controller); + void deviceAdded(AbstractController controller); +} diff --git a/app/src/main/java/com/limelight/binding/input/driver/ProConController.java b/app/src/main/java/com/limelight/binding/input/driver/ProConController.java index f46618153d..41ebf55b00 100644 --- a/app/src/main/java/com/limelight/binding/input/driver/ProConController.java +++ b/app/src/main/java/com/limelight/binding/input/driver/ProConController.java @@ -46,7 +46,7 @@ public static boolean canClaimDevice(UsbDevice device) { return (device.getVendorId() == 0x057e && device.getProductId() == 0x2009); } - public ProConController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) { + public ProConController(UsbDevice device, UsbDeviceConnection connection, int deviceId, ControllerDriverListener listener) { super(deviceId, listener, device.getVendorId(), device.getProductId()); this.device = device; this.connection = connection; diff --git a/app/src/main/java/com/limelight/binding/input/driver/SteamController.java b/app/src/main/java/com/limelight/binding/input/driver/SteamController.java new file mode 100644 index 0000000000..ff6ef7bdd4 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/driver/SteamController.java @@ -0,0 +1,823 @@ +package com.limelight.binding.input.driver; + +import android.Manifest; +import android.bluetooth.*; +import android.content.Context; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; + +import android.util.Log; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresPermission; +import com.limelight.LimeLog; +import com.limelight.nvstream.input.ControllerPacket; +import com.limelight.nvstream.jni.MoonBridge; +import org.bouncycastle.util.encoders.Hex; +import org.jspecify.annotations.NonNull; + +import java.io.Closeable; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static android.bluetooth.BluetoothDevice.TRANSPORT_LE; + +/** + * The methods in this class are inspired by SDL's Steam Controller HID device: + * HIDDeviceBLESteamController.java + */ +public class SteamController extends AbstractController { + + private static final String TAG = "steamcontroller"; + + private static final int BLEButtonChunk1 = 0x10; + private static final int BLEButtonChunk2 = 0x20; + private static final int BLEButtonChunk3 = 0x40; + private static final int BLELeftJoystickChunk = 0x80; + private static final int BLELeftTrackpadChunk = 0x100; + private static final int BLERightTrackpadChunk = 0x200; + private static final int BLEIMUAccelChunk = 0x400; + private static final int BLEIMUGyroChunk = 0x800; + private static final int BLEIMUQuatChunk = 0x1000; + + // Feature Report Command Reference: https://github.com/torvalds/linux/blob/master/drivers/hid/hid-steam.c#L85 + private static final byte FEATURE_REPORT_ID = (byte)0xC0; + private static final byte CMD_CLEAR_DIGITAL_MAPPINGS = (byte)0x81; + private static final byte CMD_SET_SETTINGS_VALUES = (byte)0x87; + private static final byte SETTING_LPAD_MODE = (byte)0x07; + private static final byte SETTING_RPAD_MODE = (byte)0x08; + private static final byte SETTING_RPAD_MARGIN = (byte)0x18; + private static final byte SETTING_GYRO_MODE = (byte)0x30; + + private static final short TRACKPAD_MODE_DISABLED = 0x07; + + // Valve Corporation + private static final int VALVE_USB_VID = 0x28DE; + + static final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3"); + static final UUID inputCharacteristicD0G = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3"); + static final UUID inputCharacteristicTriton = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3"); + static final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3"); + private static final byte[] setGamepadModeCommand = new byte[] { (byte)0xC0, CMD_SET_SETTINGS_VALUES, 0x0C, // Length + SETTING_LPAD_MODE, 0x07, 0x00, // Disable cursor + SETTING_RPAD_MODE, 0x07, 0x00, // Disable mouse + SETTING_RPAD_MARGIN, 0x00, 0x00, // No margin + SETTING_GYRO_MODE, /*0x1F*/0x00, 0x00, // Disable gyro/accel + }; + + private final Callback mCallback; + + public SteamController(ControllerDriverListener listener, BluetoothDriverService manager, int deviceId, BluetoothDevice device) { + super(deviceId, listener, VALVE_USB_VID, 0); + type = MoonBridge.LI_CTYPE_PS; + capabilities = + MoonBridge.LI_CCAP_ANALOG_TRIGGERS | MoonBridge.LI_CCAP_RUMBLE | MoonBridge.LI_CCAP_TRIGGER_RUMBLE | + MoonBridge.LI_CCAP_ACCEL | MoonBridge.LI_CCAP_GYRO; + buttonFlags = + ControllerPacket.A_FLAG | ControllerPacket.B_FLAG | ControllerPacket.X_FLAG | ControllerPacket.Y_FLAG | + ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG | + ControllerPacket.LB_FLAG | ControllerPacket.RB_FLAG | + ControllerPacket.LS_CLK_FLAG | ControllerPacket.RS_CLK_FLAG | + ControllerPacket.BACK_FLAG | ControllerPacket.PLAY_FLAG | ControllerPacket.SPECIAL_BUTTON_FLAG; + + mCallback = new Callback(manager, device); + } + + protected boolean handleRead(ByteBuffer buffer) { + buffer.order(ByteOrder.LITTLE_ENDIAN); + int version = Byte.toUnsignedInt(buffer.get()); // skip first byte + if (version != 0xC0) { + Log.d(TAG, "Unknown report version: " + version); + return false; + } + + int type = Byte.toUnsignedInt(buffer.get()) | Byte.toUnsignedInt(buffer.get()) << 8; + if (type == 4) { + // This is a special report that only contains IMU data. We can ignore it, as we get the same data in our regular reports. + byte[] imuData = new byte[buffer.remaining()]; + long sum = 0; + for (byte imuDatum : imuData) { + sum += Byte.toUnsignedInt(imuDatum); + } + if (sum != 0) { + Log.d(TAG, "Received non-empty IMU-only report, ignoring. Data: " + Hex.toHexString(imuData)); + } + + return true; + } + + if((type & BLEButtonChunk1) != 0) { + byte[] buttons = new byte[3]; + buffer.get(buttons); + + long b = Byte.toUnsignedLong(buttons[0]) | Byte.toUnsignedLong(buttons[1]) << 8 | Byte.toUnsignedLong(buttons[2]) << 16; + setButtonFlag(ControllerPacket.RS_CLK_FLAG, (int) (b & 0x00000001)); + setButtonFlag(ControllerPacket.LS_CLK_FLAG, (int) (b & 0x00000002)); + + setButtonFlag(ControllerPacket.RB_FLAG, (int) (b & 0x00000004)); + setButtonFlag(ControllerPacket.LB_FLAG, (int) (b & 0x00000008)); + + setButtonFlag(ControllerPacket.Y_FLAG, (int) (b & 0x00000010)); + setButtonFlag(ControllerPacket.B_FLAG, (int) (b & 0x00000020)); + setButtonFlag(ControllerPacket.X_FLAG, (int) (b & 0x00000040)); + setButtonFlag(ControllerPacket.A_FLAG, (int) (b & 0x00000080)); + + setButtonFlag(ControllerPacket.UP_FLAG, (int) (b & 0x00000100)); + setButtonFlag(ControllerPacket.RIGHT_FLAG, (int) (b & 0x00000200)); + setButtonFlag(ControllerPacket.LEFT_FLAG, (int) (b & 0x00000400)); + setButtonFlag(ControllerPacket.DOWN_FLAG, (int) (b & 0x00000800)); + + setButtonFlag(ControllerPacket.BACK_FLAG, (int) (b & 0x00001000)); + setButtonFlag(ControllerPacket.SPECIAL_BUTTON_FLAG, (int) (b & 0x00002000)); + setButtonFlag(ControllerPacket.PLAY_FLAG, (int) (b & 0x00004000)); + + Log.d(TAG, "Buttons: " + Long.toBinaryString(b)); + } + if((type & BLEButtonChunk2) != 0) { + int left = Byte.toUnsignedInt(buffer.get()); + int right = Byte.toUnsignedInt(buffer.get()); + Log.d(TAG, "Triggers: "+left+" | "+right); + leftTrigger = left/255.0f; + rightTrigger = right/255.0f; + } + if((type & BLEButtonChunk3) != 0) { + byte[] buttons = new byte[3]; + buffer.get(buttons); + } + if((type & BLELeftJoystickChunk) != 0) { + int x = buffer.getShort(); + int y = ~buffer.getShort(); + Log.d(TAG, "Joystick: "+x+" | "+y); + leftStickX = x / (float)Short.MAX_VALUE; + leftStickY = y / (float)Short.MAX_VALUE; + } + if((type & BLELeftTrackpadChunk) != 0) { + int x = buffer.getShort(); + int y = ~buffer.getShort(); + //Log.d(TAG, "IGNORED Left Trackpad: "+x+" | "+y); + } + if((type & BLERightTrackpadChunk) != 0) { + int x = buffer.getShort(); + int y = ~buffer.getShort(); + Log.d(TAG, "Right Pad: "+x+" | "+y); + rightStickX = x / (float)Short.MAX_VALUE; + rightStickY = y / (float)Short.MAX_VALUE; + } + if((type & BLEIMUAccelChunk) != 0) { + accelX = buffer.getShort() / (float)Short.MAX_VALUE; + accelY = buffer.getShort() / (float)Short.MAX_VALUE; + accelZ = buffer.getShort() / (float)Short.MAX_VALUE; + Log.d(TAG, "Accel: "+accelX+" | "+accelY+" | "+accelZ); + } + if((type & BLEIMUGyroChunk) != 0) { + gyroX = buffer.getShort() / (float)Short.MAX_VALUE; + gyroY = buffer.getShort() / (float)Short.MAX_VALUE; + gyroZ = buffer.getShort() / (float)Short.MAX_VALUE; + Log.d(TAG, "Gyro: "+gyroX+" | "+gyroY+" | "+gyroZ); + } + if((type & BLEIMUQuatChunk) != 0) { + int w = buffer.getShort(); + int x = buffer.getShort(); + int y = buffer.getShort(); + int z = 0;//buffer.getShort(); + Log.d(TAG, "IGNORED Gyro Quat: "+w+" | "+x+" | "+y+" | "+z); + } + + reportInput(); + + return true; + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public boolean start() { + return mCallback.start(); + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void stop() { + // Stop rumbling before closing connection + mCallback.rumble((short) 0, (short) 0); + + mCallback.close(); + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public int getProductId() { + return mCallback.getProductId(); + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void rumble(short lowFreqMotor, short highFreqMotor) { + // TODO: Implement rumble + Log.d(TAG, "Rumbling imaginatively: lowFreq=" + lowFreqMotor + " highFreq=" + highFreqMotor); + //mCallback.rumble(lowFreqMotor, highFreqMotor); + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void rumbleTriggers(short leftTrigger, short rightTrigger) { + Log.d(TAG, "Rumbling triggers imaginatively: leftTrigger=" + leftTrigger + " rightTrigger=" + rightTrigger); + //mCallback.rumble(leftTrigger, rightTrigger); + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void reconnect() { + mCallback.reconnect(); + } + + private class Callback extends BluetoothGattCallback implements Closeable { + private static final int D0G_BLE2_PID = 0x1106; + private static final int TRITON_BLE_PID = 0x1303; + + private final BluetoothDriverService mManager; + private final BluetoothDevice mDevice; + private BluetoothGatt mGatt; + private boolean mIsRegistered; + private boolean mIsConnected = false; + private final boolean mIsChromebook; + private boolean mIsReconnecting = false; + private final LinkedList mOperations; + GattOperation mCurrentOperation = null; + private final Handler mHandler; + private int mProductId = -1; + + private final HashMap mOutputReportChars = new HashMap<>(); + + public Callback(BluetoothDriverService manager, BluetoothDevice device) { + mManager = manager; + mDevice = device; + mIsRegistered = false; + mIsChromebook = isChromebook(); + mOperations = new LinkedList<>(); + mHandler = new Handler(Looper.getMainLooper()); + } + + private boolean isChromebook() { + // https://stackoverflow.com/questions/39784415/how-to-detect-programmatically-if-android-app-is-running-in-chrome-book-or-in + if (mManager != null) { + if (mManager.getPackageManager().hasSystemFeature("org.chromium.arc") + || mManager.getPackageManager().hasSystemFeature("org.chromium.arc.device_management")) { + return true; + } + } + + // Running on AVD emulator + boolean isChromebookEmulator = (Build.MODEL != null && Build.MODEL.startsWith("sdk_gpc_")); + return isChromebookEmulator; + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public boolean start() { + mGatt = connectGatt(); + if (mGatt == null) { + LimeLog.severe("Failed to connect GATT for Steam Controller"); + return false; + } + return true; + } + + // 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. + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private BluetoothGatt connectGatt(boolean autoConnect) { + if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) { + try { + return mDevice.connectGatt(mManager, autoConnect, mCallback, TRANSPORT_LE); + } catch (Exception e) { + return mDevice.connectGatt(mManager, autoConnect, mCallback); + } + } else { + return mDevice.connectGatt(mManager, autoConnect, mCallback); + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private BluetoothGatt connectGatt() { + return connectGatt(false); + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { + //Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState); + mIsReconnecting = false; + if (newState == BluetoothProfile.STATE_CONNECTED) { + mIsConnected = true; + // Run directly, without GattOperation + if (!isRegistered()) { + mHandler.post(() -> mGatt.discoverServices()); + } + } + else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + mIsConnected = false; + notifyDeviceRemoved(); + } + + // Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent. + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + //Log.v(TAG, "onServicesDiscovered status=" + status); + if (status == 0) { + if (gatt.getServices().isEmpty()) { + LimeLog.severe("onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack."); + mIsReconnecting = true; + mIsConnected = false; + gatt.disconnect(); + mGatt = connectGatt(); + } else { + if (getProductId() == TRITON_BLE_PID) { + // Android will not properly play well with Data Length Extensions without manually requesting a large MTU, + // and Triton controllers require DLE support. + // + // 517 is basically a "magic number" as far as Android's bluetooth code is concerned, so do not change + // this value. It is functionally "please enable data length extensions" on some Android builds. + mGatt.requestMtu(517); + } + + probeService(SteamController.this); + } + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private boolean probeService(SteamController controller) { + if (isRegistered()) { + return true; + } + + if (!mIsConnected) { + return false; + } + + LimeLog.info("probeService controller=" + controller); + + for (BluetoothGattService service : mGatt.getServices()) { + if (service.getUuid().equals(steamControllerService)) { + LimeLog.info("Found Valve steam controller service " + service.getUuid()); + + for (BluetoothGattCharacteristic chr : service.getCharacteristics()) { + boolean bShouldStartNotifications = false; + + if (chr.getUuid().equals(inputCharacteristicTriton)) { + LimeLog.info("Found Triton input characteristic"); + mProductId = TRITON_BLE_PID; + bShouldStartNotifications = true; + } else if (chr.getUuid().equals(inputCharacteristicD0G)) { + LimeLog.info("Found D0G input characteristic"); + mProductId = D0G_BLE2_PID; + bShouldStartNotifications = true; + } else { + Pattern reportPattern = Pattern.compile("100F6C([0-9A-Z]{2})", Pattern.CASE_INSENSITIVE); + Matcher matcher = reportPattern.matcher(chr.getUuid().toString()); + + if (matcher.find()) { + try { + String reportIdGroup = Objects.requireNonNull(matcher.group(1)); + int reportId = Integer.parseInt(reportIdGroup, 16); + + reportId -= 0x35; + if (reportId >= 0x80) { + // This is a Triton output report characteristic that we need to care about. + LimeLog.info("Found Triton output report 0x" + Integer.toString(reportId, 16)); + mOutputReportChars.put(reportId, chr); + } + } + catch (NumberFormatException nfe) { + LimeLog.warning("Could not parse report characteristic " + chr.getUuid().toString() + ": " + nfe.toString()); + } + } + } + + if (bShouldStartNotifications) { + // Start notifications + BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); + if (cccd != null) { + enableNotification(chr.getUuid()); + } + } + } + return true; + } + } + + if ((mGatt.getServices().isEmpty()) && mIsChromebook && !mIsReconnecting) { + LimeLog.severe("Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us."); + mIsConnected = false; + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(); + } + + return false; + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private void enableNotification(UUID chrUuid) { + GattOperation op = GattOperation.enableNotification(mGatt, chrUuid); + queueGattOperation(op); + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private void queueGattOperation(GattOperation op) { + synchronized (mOperations) { + mOperations.add(op); + } + executeNextGattOperation(); + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void onCharacteristicRead(@NonNull BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte @NonNull [] value, int status) { + //Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid()); + + if (characteristic.getUuid().equals(reportCharacteristic)) { + //mManager.HIDDeviceReportResponse(getId(), characteristic.getValue()); + // TODO: Report features + Log.v(TAG, "Report characteristic read: " + Hex.toHexString(characteristic.getValue())); + } + + finishCurrentGattOperation(); + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + 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(); + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private void executeNextGattOperation() { + synchronized (mOperations) { + if (mCurrentOperation != null) + return; + + if (mOperations.isEmpty()) + return; + + mCurrentOperation = mOperations.removeFirst(); + } + + // Run in main thread + mHandler.post(() -> { + synchronized (mOperations) { + if (mCurrentOperation == null) { + LimeLog.warning("Current operation null in executor?"); + return; + } + + mCurrentOperation.run(); + // now wait for the GATT callback and when it comes, finish this operation + } + }); + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + 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()) { + LimeLog.info("Registering Steam Controller with ID: " + getIdentifier()); + //mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true); + setRegistered(); + notifyDeviceAdded(); + } + } + + finishCurrentGattOperation(); + } + + private String getIdentifier() { + return String.format("SteamController.%s", mDevice.getAddress()); + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void onCharacteristicChanged(@NonNull BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte @NonNull [] value) { + // Enable this for verbose logging of controller input reports + //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + Arrays.toString(characteristic.getValue())); + + if (characteristic.getUuid().equals(getInputCharacteristic())) { + //mManager.HIDDeviceInputReport(getId(), characteristic.getValue()); + handleRead(ByteBuffer.wrap(characteristic.getValue())); + } + } + + @Override + public void onDescriptorRead(@NonNull BluetoothGatt gatt, @NonNull BluetoothGattDescriptor descriptor, int status, byte @NonNull [] value) { + Log.v(TAG, "onDescriptorRead status=" + status); + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + 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(getInputCharacteristic())) { + BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic); + if (reportChr != null) { + if (getProductId() == TRITON_BLE_PID) { + // For Triton we just mark things registered. + LimeLog.info("Registering Triton Steam Controller with ID: " + getControllerId()); + //mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true); + setRegistered(); + } else { + // For the original controller, we need to manually enter Valve mode. + LimeLog.info("Writing report characteristic to enter valve mode"); + /*reportChr.setValue(clearMappingsCommand); + gatt.writeCharacteristic(reportChr); + reportChr.setValue(enterValveMode); + gatt.writeCharacteristic(reportChr);*/ + enableLizardMode(); + } + } + } + + finishCurrentGattOperation(); + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private int getProductId() { + if (mProductId > 0) { + // We've already set a product ID. + return mProductId; + } + + if (mDevice.getName().startsWith("Steam Ctrl")) { + // We're a newer Triton device + mProductId = TRITON_BLE_PID; + } else { + // We're an OG Steam Controller + mProductId = D0G_BLE2_PID; + } + + return mProductId; + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void reconnect() { + if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) { + mGatt.disconnect(); + mGatt = connectGatt(); + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private int getConnectionState() { + Context context = mManager; + 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 enableLizardMode() { + // Disable esc, enter, cursor + sendReportCommand(ReportCommand.CLEAR_DIGITAL_MAPPINGS, null); + + // Disable mouse + ByteBuffer settingsData = ByteBuffer.allocate(3).order(ByteOrder.LITTLE_ENDIAN); + settingsData.put(SETTING_LPAD_MODE); + settingsData.putShort(TRACKPAD_MODE_DISABLED); + settingsData.put(SETTING_RPAD_MODE); + settingsData.putShort(TRACKPAD_MODE_DISABLED); + sendReportCommand(ReportCommand.SET_SETTINGS_VALUES, settingsData); + } + + public void disableLizardMode() { + // Enable esc, enter, cursors + sendReportCommand(ReportCommand.SET_DEFAULT_DIGITAL_MAPPINGS, null); + // Reset settings + sendReportCommand(ReportCommand.LOAD_DEFAULT_SETTINGS, null); + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void rumble(short leftSpeed, short rightSpeed) { + // Modeled after the rumble command in Linux's hid-steam driver: + // https://github.com/torvalds/linux/blob/master/drivers/hid/hid-steam.c#L514-L555 + ByteBuffer rumbleData = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN); + rumbleData.put((byte)0) // Amplitude low byte + .put((byte)0) // Amplitude high byte + .putShort(leftSpeed) + .putShort(rightSpeed) + .put((byte)2) // Left gain + .put((byte)0); // Right gain + + sendReportCommand(ReportCommand.TRIGGER_RUMBLE, rumbleData); + } + + private void sendReportCommand(ReportCommand reportCommand, @Nullable ByteBuffer payload) { + int payloadLength = 0; + if (payload != null) { + payload.rewind(); + payloadLength = payload.remaining(); + } + + byte[] command = new byte[3 + payloadLength]; + command[0] = FEATURE_REPORT_ID; + command[1] = reportCommand.getValue(); + command[2] = (byte)(payloadLength + 1); // Length includes the length itself + if (payload != null) { + payload.get(command, 3, payloadLength); + } + + queueGattOperation(GattOperation.writeCharacteristic(mGatt, reportCharacteristic, command)); + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void close() { + BluetoothGatt g = mGatt; + if (g != null) { + if (getProductId() == D0G_BLE2_PID) { + disableLizardMode(); + } + + g.disconnect(); + g.close(); + mGatt = null; + } + mIsRegistered = false; + mIsConnected = false; + mOperations.clear(); + } + + private boolean isRegistered() { + return mIsRegistered; + } + + private void setRegistered() { + mIsRegistered = true; + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private UUID getInputCharacteristic() { + if (getProductId() == TRITON_BLE_PID) { + return inputCharacteristicTriton; + } else { + return inputCharacteristicD0G; + } + } + } + + 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; + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + 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)) { + LimeLog.severe("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)) { + LimeLog.severe("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 { + LimeLog.severe("Unable to start notifications on input characteristic"); + mResult = false; + return; + } + + mGatt.setCharacteristicNotification(chr, true); + cccd.setValue(value); + if (!mGatt.writeDescriptor(cccd)) { + LimeLog.severe("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); + } + } + + private enum ReportCommand { + CLEAR_DIGITAL_MAPPINGS((byte)0x81), + SET_DEFAULT_DIGITAL_MAPPINGS((byte)0x85), + SET_SETTINGS_VALUES((byte)0x87), + LOAD_DEFAULT_SETTINGS((byte)0x8E), + TRIGGER_RUMBLE((byte)0xEB); + + private final byte value; + ReportCommand(byte value) { + this.value = value; + } + + public byte getValue() { + return value; + } + } +} diff --git a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java index 49792b4dc6..0125550f41 100755 --- a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java +++ b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java @@ -21,10 +21,12 @@ import com.limelight.R; import com.limelight.preferences.PreferenceConfiguration; -import java.io.File; import java.util.ArrayList; +import java.util.concurrent.atomic.AtomicInteger; -public class UsbDriverService extends Service implements UsbDriverListener { +public class UsbDriverService extends Service implements ControllerDriverListener { + + private static final AtomicInteger NEXT_DEVICE_ID = new AtomicInteger(0); private static final String ACTION_USB_PERMISSION = "com.limelight.USB_PERMISSION"; @@ -38,9 +40,12 @@ public class UsbDriverService extends Service implements UsbDriverListener { private final ArrayList controllers = new ArrayList<>(); - private UsbDriverListener listener; + private ControllerDriverListener listener; private UsbDriverStateListener stateListener; - private int nextDeviceId; + + public static int getNextDeviceId() { + return NEXT_DEVICE_ID.getAndIncrement(); + } @Override public void reportControllerState(int controllerId, int buttonFlags, float leftStickX, float leftStickY, @@ -120,7 +125,7 @@ else if (action.equals(ACTION_USB_PERMISSION)) { } public class UsbDriverBinder extends Binder { - public void setListener(UsbDriverListener listener) { + public void setListener(ControllerDriverListener listener) { UsbDriverService.this.listener = listener; // Report all controllerMap that already exist @@ -194,16 +199,16 @@ private void handleUsbDeviceState(UsbDevice device) { AbstractController controller; if (XboxOneController.canClaimDevice(device)) { - controller = new XboxOneController(device, connection, nextDeviceId++, this); + controller = new XboxOneController(device, connection, getNextDeviceId(), this); } else if (Xbox360Controller.canClaimDevice(device)) { - controller = new Xbox360Controller(device, connection, nextDeviceId++, this); + controller = new Xbox360Controller(device, connection, getNextDeviceId(), this); } else if (Xbox360WirelessDongle.canClaimDevice(device)) { - controller = new Xbox360WirelessDongle(device, connection, nextDeviceId++, this); + controller = new Xbox360WirelessDongle(device, connection, getNextDeviceId(), this); } else if (ProConController.canClaimDevice(device)) { - controller = new ProConController(device, connection, nextDeviceId++, this); + controller = new ProConController(device, connection, getNextDeviceId(), this); } else { // Unreachable diff --git a/app/src/main/java/com/limelight/binding/input/driver/Xbox360Controller.java b/app/src/main/java/com/limelight/binding/input/driver/Xbox360Controller.java index 0dd1dca70c..22e9f1eacf 100755 --- a/app/src/main/java/com/limelight/binding/input/driver/Xbox360Controller.java +++ b/app/src/main/java/com/limelight/binding/input/driver/Xbox360Controller.java @@ -58,7 +58,7 @@ public static boolean canClaimDevice(UsbDevice device) { return false; } - public Xbox360Controller(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) { + public Xbox360Controller(UsbDevice device, UsbDeviceConnection connection, int deviceId, ControllerDriverListener listener) { super(device, connection, deviceId, listener); } diff --git a/app/src/main/java/com/limelight/binding/input/driver/Xbox360WirelessDongle.java b/app/src/main/java/com/limelight/binding/input/driver/Xbox360WirelessDongle.java index 91ae2be1fc..12b62f491f 100755 --- a/app/src/main/java/com/limelight/binding/input/driver/Xbox360WirelessDongle.java +++ b/app/src/main/java/com/limelight/binding/input/driver/Xbox360WirelessDongle.java @@ -5,13 +5,10 @@ import android.hardware.usb.UsbDeviceConnection; import android.hardware.usb.UsbEndpoint; import android.hardware.usb.UsbInterface; -import android.os.Build; import android.view.InputDevice; import com.limelight.LimeLog; -import java.nio.ByteBuffer; - public class Xbox360WirelessDongle extends AbstractController { private UsbDevice device; private UsbDeviceConnection connection; @@ -37,7 +34,7 @@ public static boolean canClaimDevice(UsbDevice device) { return false; } - public Xbox360WirelessDongle(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) { + public Xbox360WirelessDongle(UsbDevice device, UsbDeviceConnection connection, int deviceId, ControllerDriverListener listener) { super(deviceId, listener, device.getVendorId(), device.getProductId()); this.device = device; this.connection = connection; diff --git a/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java b/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java index 456367e62d..729fecb2a1 100755 --- a/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java +++ b/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java @@ -66,7 +66,7 @@ public class XboxOneController extends AbstractXboxController { private short leftTriggerMotor = 0; private short rightTriggerMotor = 0; - public XboxOneController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) { + public XboxOneController(UsbDevice device, UsbDeviceConnection connection, int deviceId, ControllerDriverListener listener) { super(device, connection, deviceId, listener); capabilities |= MoonBridge.LI_CCAP_TRIGGER_RUMBLE; } diff --git a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java index 96249d87ac..f207171740 100755 --- a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java +++ b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java @@ -61,6 +61,7 @@ public enum AnalogStickForScrolling { private static final String MULTI_CONTROLLER_PREF_STRING = "checkbox_multi_controller"; static final String AUDIO_CONFIG_PREF_STRING = "list_audio_config"; private static final String USB_DRIVER_PREF_SRING = "checkbox_usb_driver"; + private static final String BLUETOOTH_DRIVER_PREF_SRING = "checkbox_bluetooth_driver"; private static final String VIDEO_FORMAT_PREF_STRING = "video_format"; private static final String ONSCREEN_CONTROLLER_PREF_STRING = "checkbox_show_onscreen_controls"; private static final String CHECKBOX_HIDE_OSC_WHEN_HAS_GAMEPAD = "checkbox_hide_osc_when_has_gamepad"; @@ -155,6 +156,7 @@ public enum AnalogStickForScrolling { public static final String DEFAULT_LANGUAGE = "default"; private static final boolean DEFAULT_MULTI_CONTROLLER = true; private static final boolean DEFAULT_USB_DRIVER = true; + private static final boolean DEFAULT_BLUETOOTH_DRIVER = false; private static final String DEFAULT_VIDEO_FORMAT = "auto"; private static final boolean DEFAULT_ONSCREEN_CONTROLLER = false; @@ -248,7 +250,7 @@ public enum AnalogStickForScrolling { public ScaleMode videoScaleMode; public String language; public int renderMode; - public boolean smallIconMode, multiController, usbDriver, flipFaceButtons; + public boolean smallIconMode, multiController, usbDriver, bluetoothDriver, flipFaceButtons; public boolean onscreenController; public boolean hideOSCWhenHasGamepad; public boolean enableBatteryReport; @@ -885,6 +887,7 @@ else if (audioConfig.equals("51")) { config.smallIconMode = prefs.getBoolean(SMALL_ICONS_PREF_STRING, getDefaultSmallMode(context)); config.multiController = prefs.getBoolean(MULTI_CONTROLLER_PREF_STRING, DEFAULT_MULTI_CONTROLLER); config.usbDriver = prefs.getBoolean(USB_DRIVER_PREF_SRING, DEFAULT_USB_DRIVER); + config.bluetoothDriver = prefs.getBoolean(BLUETOOTH_DRIVER_PREF_SRING, DEFAULT_BLUETOOTH_DRIVER); config.fullScreen = prefs.getBoolean(FULL_SCREEN_PREF_STRING, DEFAULT_FULL_SCREEN); String renderMode = prefs.getString("render_mode_list", "0"); From 3518da3aca1bdd5b1c8a42bbedb69b55bb62042e Mon Sep 17 00:00:00 2001 From: Philipp Kapfer Date: Fri, 27 Feb 2026 10:10:36 +0100 Subject: [PATCH 2/6] Expose preference for Steam Controller --- .../main/java/com/limelight/preferences/StreamSettings.java | 6 ++++++ app/src/main/res/values/strings.xml | 3 +++ app/src/main/res/xml/preferences.xml | 6 ++++++ 3 files changed, 15 insertions(+) diff --git a/app/src/main/java/com/limelight/preferences/StreamSettings.java b/app/src/main/java/com/limelight/preferences/StreamSettings.java index 5b2d20de13..d6773ec16e 100755 --- a/app/src/main/java/com/limelight/preferences/StreamSettings.java +++ b/app/src/main/java/com/limelight/preferences/StreamSettings.java @@ -385,6 +385,12 @@ public void initializePreferences() { category.removePreference(findPreference("checkbox_usb_driver")); } + if (!pm.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { + PreferenceCategory category = + (PreferenceCategory) findPreference("category_gamepad_settings"); + category.removePreference(findPreference("checkbox_bluetooth_driver")); + } + // Remove PiP mode on devices pre-Oreo, where the feature is not available (some low RAM devices), // and on Fire OS where it violates the Amazon App Store guidelines for some reason. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 95f64c92a5..ab26931c4f 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -234,6 +234,8 @@ Enables a built-in USB driver for devices without native Xbox/Nintendo Pro controller support Override native Xbox gamepad support Use Artemis\'s USB driver for all supported gamepads, even if native Xbox/Nintendo Pro controller support is present + Steam Controller Bluetooth gamepad driver + Enables a built-in Bluetooth driver for the Steam Controller Mouse emulation via gamepad Long-press the Start button to toggle between gamepad and mouse mode Remember mouse mode @@ -638,6 +640,7 @@ External Display Active Tap to open touchpad controller Notification permission denied + Permission denied. Steam Controllers will not work. No external display detected No game instance available Failed to get clipboard: diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 5b009c891f..73d2391529 100755 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -271,6 +271,12 @@ android:summary="@string/summary_checkbox_usb_bind_all" android:title="@string/title_checkbox_usb_bind_all" app:iconSpaceReserved="false" /> + Date: Fri, 27 Feb 2026 10:10:55 +0100 Subject: [PATCH 3/6] Ask for Bluetooth permission when Steam Controller should be used --- app/src/main/java/com/limelight/Game.java | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/app/src/main/java/com/limelight/Game.java b/app/src/main/java/com/limelight/Game.java index 7deac73709..0bdc7d23b5 100755 --- a/app/src/main/java/com/limelight/Game.java +++ b/app/src/main/java/com/limelight/Game.java @@ -8,6 +8,10 @@ import static com.limelight.utils.ServerHelper.getActiveDisplay; import static com.limelight.utils.ServerHelper.getSecondaryDisplay; +import android.Manifest; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.core.content.ContextCompat; import com.limelight.binding.PlatformBinding; import com.limelight.binding.audio.AndroidAudioRenderer; import com.limelight.binding.input.ControllerHandler; @@ -269,6 +273,16 @@ public void onServiceDisconnected(ComponentName componentName) { connectedToBluetoothDriverService = false; } }; + private final ActivityResultLauncher requestBluetoothPermissionLauncher = + this.registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { + if (isGranted) { + bindService(new Intent(this, BluetoothDriverService.class), + bluetoothDriverServiceConnection, Service.BIND_AUTO_CREATE); + } else { + Toast.makeText(this, getString(R.string.bluetooth_permission_denied), Toast.LENGTH_LONG).show(); + } + }); + public static final String EXTRA_HOST = "Host"; public static final String EXTRA_PORT = "Port"; public static final String EXTRA_HTTPS_PORT = "HttpsPort"; @@ -3730,8 +3744,12 @@ public void run() { usbDriverServiceConnection, Service.BIND_AUTO_CREATE); } if (prefConfig.bluetoothDriver) { + if (hasBluetoothPermission()) { bindService(new Intent(this, BluetoothDriverService.class), bluetoothDriverServiceConnection, Service.BIND_AUTO_CREATE); + } else { + requestBluetoothPermissionLauncher.launch(getBluetoothPermission()); + } } // Report this shortcut being used (off the main thread to prevent ANRs) @@ -3746,6 +3764,19 @@ public void run() { } } + private boolean hasBluetoothPermission() { + return ContextCompat.checkSelfPermission(getApplicationContext(), getBluetoothPermission()) + == PackageManager.PERMISSION_GRANTED; + } + + private String getBluetoothPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return Manifest.permission.BLUETOOTH_CONNECT; + } else { + return Manifest.permission.BLUETOOTH; + } + } + @Override public void displayMessage(final String message) { runOnUiThread(new Runnable() { From 73bb2bc00eea1ce72f147f1a065f81df8369b1e1 Mon Sep 17 00:00:00 2001 From: Philipp Kapfer Date: Tue, 3 Mar 2026 13:32:00 +0100 Subject: [PATCH 4/6] Allow motion and battery events to be handled by USB/Bluetooth controllers --- .../binding/input/ControllerHandler.java | 193 +++++++++++------- .../input/driver/AbstractController.java | 16 ++ .../driver/ControllerDriverListener.java | 2 + .../input/driver/UsbDriverService.java | 8 + 4 files changed, 146 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/com/limelight/binding/input/ControllerHandler.java b/app/src/main/java/com/limelight/binding/input/ControllerHandler.java index 31fcadb33d..4222efca76 100755 --- a/app/src/main/java/com/limelight/binding/input/ControllerHandler.java +++ b/app/src/main/java/com/limelight/binding/input/ControllerHandler.java @@ -2353,14 +2353,11 @@ public void onAccuracyChanged(Sensor sensor, int accuracy) {} }; } - public void handleSetMotionEventState(final short controllerNumber, final byte motionType, short reportRateHz) { + public void handleSetMotionEventState(final short controllerNumber, final byte motionType, final short reportRateHz) { if (stopped) { return; } - // Report rate is restricted to <= 200 Hz without the HIGH_SAMPLING_RATE_SENSORS permission - reportRateHz = (short) Math.min(200, reportRateHz); - for (int i = 0; i < inputDeviceContexts.size() + usbDeviceContexts.size(); i++) { InputDeviceContext deviceContext; if (i < inputDeviceContexts.size()) { @@ -2370,54 +2367,7 @@ public void handleSetMotionEventState(final short controllerNumber, final byte m } if (deviceContext.controllerNumber == controllerNumber) { - // Store the desired report rate even if we don't have sensors. In some cases, - // input devices can be reconfigured at runtime which results in a change where - // sensors disappear and reappear. By storing the desired report rate, we can - // reapply the desired motion sensor configuration after they reappear. - switch (motionType) { - case MoonBridge.LI_MOTION_TYPE_ACCEL: - deviceContext.accelReportRateHz = reportRateHz; - break; - case MoonBridge.LI_MOTION_TYPE_GYRO: - deviceContext.gyroReportRateHz = reportRateHz; - break; - } - - backgroundThreadHandler.removeCallbacks(deviceContext.enableSensorRunnable); - - SensorManager sm = deviceContext.sensorManager; - if (sm == null) { - continue; - } - - switch (motionType) { - case MoonBridge.LI_MOTION_TYPE_ACCEL: - if (deviceContext.accelListener != null) { - sm.unregisterListener(deviceContext.accelListener); - deviceContext.accelListener = null; - } - - // Enable the accelerometer if requested - Sensor accelSensor = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); - if (reportRateHz != 0 && accelSensor != null) { - deviceContext.accelListener = createSensorListener(controllerNumber, motionType, sm == deviceSensorManager); - sm.registerListener(deviceContext.accelListener, accelSensor, 1000000 / reportRateHz); - } - break; - case MoonBridge.LI_MOTION_TYPE_GYRO: - if (deviceContext.gyroListener != null) { - sm.unregisterListener(deviceContext.gyroListener); - deviceContext.gyroListener = null; - } - - // Enable the gyroscope if requested - Sensor gyroSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE); - if (reportRateHz != 0 && gyroSensor != null) { - deviceContext.gyroListener = createSensorListener(controllerNumber, motionType, sm == deviceSensorManager); - sm.registerListener(deviceContext.gyroListener, gyroSensor, 1000000 / reportRateHz); - } - break; - } + deviceContext.setMotionEventState(motionType, reportRateHz); break; } } @@ -2429,30 +2379,17 @@ public void handleSetControllerLED(short controllerNumber, byte r, byte g, byte } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - for (int i = 0; i < inputDeviceContexts.size(); i++) { - InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); + for (int i = 0; i < inputDeviceContexts.size() + usbDeviceContexts.size(); i++) { + InputDeviceContext deviceContext; + if (i < inputDeviceContexts.size()) { + deviceContext = inputDeviceContexts.valueAt(i); + } else { + deviceContext = usbDeviceContexts.valueAt(i - inputDeviceContexts.size()); + } // Ignore input devices without an RGB LED if (deviceContext.controllerNumber == controllerNumber && deviceContext.hasRgbLed) { - // Create a new light session if one doesn't already exist - if (deviceContext.lightsSession == null) { - deviceContext.lightsSession = deviceContext.inputDevice.getLightsManager().openSession(); - } - - // Convert the RGB components into the integer value that LightState uses - int argbValue = 0xFF000000 | ((r << 16) & 0xFF0000) | ((g << 8) & 0xFF00) | (b & 0xFF); - LightState lightState = new LightState.Builder().setColor(argbValue).build(); - - // Set the RGB value for each RGB-controllable LED on the device - LightsRequest.Builder lightsRequestBuilder = new LightsRequest.Builder(); - for (Light light : deviceContext.inputDevice.getLightsManager().getLights()) { - if (light.hasRgbControl()) { - lightsRequestBuilder.addLight(light, lightState); - } - } - - // Apply the LED changes - deviceContext.lightsSession.requestLights(lightsRequestBuilder.build()); + deviceContext.setControllerLED(r, g, b); } } } @@ -3007,6 +2944,16 @@ public void reportControllerMotion(int controllerId, byte motionType, float moti conn.sendControllerMotionEvent((byte)context.controllerNumber, motionType, motionX, motionY, motionZ); } + @Override + public void reportBatteryState(int controllerId, byte batteryState, byte batteryPercentage) { + GenericControllerContext context = usbDeviceContexts.get(controllerId); + if (context == null) { + return; + } + + conn.sendControllerBatteryEvent((byte)context.controllerNumber, batteryState, batteryPercentage); + } + @Override public void deviceRemoved(AbstractController controller) { UsbDeviceContext context = usbDeviceContexts.get(controller.getControllerId()); @@ -3404,6 +3351,86 @@ public void migrateContext(InputDeviceContext oldContext) { backgroundThreadHandler.post(batteryStateUpdateRunnable); } + public void setMotionEventState(final byte motionType, short reportRateHz) { + // Report rate is restricted to <= 200 Hz without the HIGH_SAMPLING_RATE_SENSORS permission + reportRateHz = (short) Math.min(200, reportRateHz); + + // Store the desired report rate even if we don't have sensors. In some cases, + // input devices can be reconfigured at runtime which results in a change where + // sensors disappear and reappear. By storing the desired report rate, we can + // reapply the desired motion sensor configuration after they reappear. + switch (motionType) { + case MoonBridge.LI_MOTION_TYPE_ACCEL: + accelReportRateHz = reportRateHz; + break; + case MoonBridge.LI_MOTION_TYPE_GYRO: + gyroReportRateHz = reportRateHz; + break; + } + + backgroundThreadHandler.removeCallbacks(enableSensorRunnable); + + SensorManager sm = sensorManager; + if (sm == null) { + return; + } + + switch (motionType) { + case MoonBridge.LI_MOTION_TYPE_ACCEL: + if (accelListener != null) { + sm.unregisterListener(accelListener); + accelListener = null; + } + + // Enable the accelerometer if requested + Sensor accelSensor = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); + if (reportRateHz != 0 && accelSensor != null) { + accelListener = createSensorListener(controllerNumber, motionType, sm == deviceSensorManager); + sm.registerListener(accelListener, accelSensor, 1000000 / reportRateHz); + } + break; + case MoonBridge.LI_MOTION_TYPE_GYRO: + if (gyroListener != null) { + sm.unregisterListener(gyroListener); + gyroListener = null; + } + + // Enable the gyroscope if requested + Sensor gyroSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE); + if (reportRateHz != 0 && gyroSensor != null) { + gyroListener = createSensorListener(controllerNumber, motionType, sm == deviceSensorManager); + sm.registerListener(gyroListener, gyroSensor, 1000000 / reportRateHz); + } + break; + } + } + + public void setControllerLED(byte r, byte g, byte b) { + if (!hasRgbLed || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return; + } + + // Create a new light session if one doesn't already exist + if (lightsSession == null) { + lightsSession = inputDevice.getLightsManager().openSession(); + } + + // Convert the RGB components into the integer value that LightState uses + int argbValue = 0xFF000000 | ((r << 16) & 0xFF0000) | ((g << 8) & 0xFF00) | (b & 0xFF); + LightState lightState = new LightState.Builder().setColor(argbValue).build(); + + // Set the RGB value for each RGB-controllable LED on the device + LightsRequest.Builder lightsRequestBuilder = new LightsRequest.Builder(); + for (Light light : inputDevice.getLightsManager().getLights()) { + if (light.hasRgbControl()) { + lightsRequestBuilder.addLight(light, lightState); + } + } + + // Apply the LED changes + lightsSession.requestLights(lightsRequestBuilder.build()); + } + public void disableSensors() { // Stop any pending enablement backgroundThreadHandler.removeCallbacks(enableSensorRunnable); @@ -3465,8 +3492,28 @@ public void sendControllerArrival() { }); } + hasRgbLed = (capabilities & MoonBridge.LI_CCAP_RGB_LED) != 0; + conn.sendControllerArrivalEvent((byte)controllerNumber, getActiveControllerMask(), type, device.getSupportedButtonFlags(), capabilities); } + + @Override + public void setMotionEventState(byte motionType, short reportRateHz) { + if (((device.getCapabilities() & MoonBridge.LI_CCAP_GYRO) | (device.getCapabilities() & MoonBridge.LI_CCAP_ACCEL)) == 0) { + super.setMotionEventState(motionType, reportRateHz); + } else { + device.setMotionEventState(motionType, reportRateHz); + } + } + + @Override + public void setControllerLED(byte r, byte g, byte b) { + if ((device.getCapabilities() & MoonBridge.LI_CCAP_RGB_LED) == 0) { + super.setControllerLED(r, g, b); + } else { + device.setControllerLED(r, g, b); + } + } } } diff --git a/app/src/main/java/com/limelight/binding/input/driver/AbstractController.java b/app/src/main/java/com/limelight/binding/input/driver/AbstractController.java index fbe7a3536f..e88c866cb4 100755 --- a/app/src/main/java/com/limelight/binding/input/driver/AbstractController.java +++ b/app/src/main/java/com/limelight/binding/input/driver/AbstractController.java @@ -62,6 +62,10 @@ protected void reportMotion() { listener.reportControllerMotion(deviceId, MoonBridge.LI_MOTION_TYPE_ACCEL, accelX, accelY, accelZ); } + protected void reportBatteryState(byte batteryState, byte batteryPercentage) { + listener.reportBatteryState(deviceId, batteryState, batteryPercentage); + } + public abstract boolean start(); public abstract void stop(); @@ -73,6 +77,18 @@ public AbstractController(int deviceId, ControllerDriverListener listener, int v this.productId = productId; } + public void setMotionEventState(byte motionType, short reportRateHz) { + if (((getCapabilities() & MoonBridge.LI_CCAP_GYRO) | (getCapabilities() & MoonBridge.LI_CCAP_ACCEL)) != 0) { + throw new IllegalStateException("Controllers with motion capabilities must override this method"); + } + } + + public void setControllerLED(byte r, byte g, byte b) { + if ((getCapabilities() & MoonBridge.LI_CCAP_RGB_LED) != 0) { + throw new IllegalStateException("Controllers with RGB LED capability must override this method"); + } + } + public abstract void rumble(short lowFreqMotor, short highFreqMotor); public abstract void rumbleTriggers(short leftTrigger, short rightTrigger); diff --git a/app/src/main/java/com/limelight/binding/input/driver/ControllerDriverListener.java b/app/src/main/java/com/limelight/binding/input/driver/ControllerDriverListener.java index 789bf04eae..616de868e8 100644 --- a/app/src/main/java/com/limelight/binding/input/driver/ControllerDriverListener.java +++ b/app/src/main/java/com/limelight/binding/input/driver/ControllerDriverListener.java @@ -7,6 +7,8 @@ void reportControllerState(int controllerId, int buttonFlags, float leftTrigger, float rightTrigger); void reportControllerMotion(int controllerId, byte motionType, float motionX, float motionY, float motionZ); + void reportBatteryState(int controllerId, byte batteryState, byte batteryPercentage); + void deviceRemoved(AbstractController controller); void deviceAdded(AbstractController controller); } diff --git a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java index 0125550f41..798cb0ab86 100755 --- a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java +++ b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java @@ -64,6 +64,14 @@ public void reportControllerMotion(int controllerId, byte motionType, float moti } } + @Override + public void reportBatteryState(int controllerId, byte batteryState, byte batteryPercentage) { + // Call through to the client's listener + if (listener != null) { + listener.reportBatteryState(controllerId, batteryState, batteryPercentage); + } + } + @Override public void deviceRemoved(AbstractController controller) { // Remove the the controller from our list (if not removed already) From 2c71bbf896125074f3e3496ce141485428403bf0 Mon Sep 17 00:00:00 2001 From: Philipp Kapfer Date: Tue, 3 Mar 2026 13:43:45 +0100 Subject: [PATCH 5/6] Extended Steam Controller support - Allow emulating Xbox or PS5 controllers, both with their own advantages and disadvantages - Added support for gyroscope, battery and LED - Implemented rumble - Properly reset the controller when stopping to stream --- app/src/main/java/com/limelight/Game.java | 1 + .../input/driver/BluetoothDriverService.java | 10 +- .../binding/input/driver/SteamController.java | 532 ++++++++++++++---- .../preferences/PreferenceConfiguration.java | 30 +- .../limelight/preferences/StreamSettings.java | 1 + app/src/main/res/values/arrays.xml | 9 + app/src/main/res/values/strings.xml | 6 + app/src/main/res/xml/preferences.xml | 9 + 8 files changed, 481 insertions(+), 117 deletions(-) diff --git a/app/src/main/java/com/limelight/Game.java b/app/src/main/java/com/limelight/Game.java index 0bdc7d23b5..33c19e98e7 100755 --- a/app/src/main/java/com/limelight/Game.java +++ b/app/src/main/java/com/limelight/Game.java @@ -264,6 +264,7 @@ public void onServiceDisconnected(ComponentName componentName) { public void onServiceConnected(ComponentName componentName, IBinder iBinder) { BluetoothDriverService.BluetoothDriverBinder binder = (BluetoothDriverService.BluetoothDriverBinder) iBinder; binder.setListener(controllerHandler); + binder.setPreferenceConfiguration(prefConfig); binder.start(); connectedToBluetoothDriverService = true; } diff --git a/app/src/main/java/com/limelight/binding/input/driver/BluetoothDriverService.java b/app/src/main/java/com/limelight/binding/input/driver/BluetoothDriverService.java index aad6765855..d58edda4d4 100644 --- a/app/src/main/java/com/limelight/binding/input/driver/BluetoothDriverService.java +++ b/app/src/main/java/com/limelight/binding/input/driver/BluetoothDriverService.java @@ -15,6 +15,7 @@ import android.util.Log; import androidx.annotation.RequiresPermission; import com.limelight.LimeLog; +import com.limelight.preferences.PreferenceConfiguration; import java.util.ArrayList; import java.util.HashMap; @@ -29,6 +30,7 @@ public class BluetoothDriverService extends Service { private Handler mHandler; private BluetoothManager mBluetoothManager; private List mLastBluetoothDevices; + private PreferenceConfiguration prefConfig; private final BroadcastReceiver mBluetoothBroadcast = new BluetoothEventReceiver(); private final BluetoothDriverBinder binder = new BluetoothDriverBinder(); @@ -139,8 +141,10 @@ public void connectBluetoothDevice(BluetoothDevice bluetoothDevice) { SteamController device = mBluetoothDevices.get(bluetoothDevice); device.reconnect(); + return; } - SteamController device = new SteamController(listener, this, UsbDriverService.getNextDeviceId(), bluetoothDevice); + SteamController device = new SteamController(listener, this, UsbDriverService.getNextDeviceId(), + bluetoothDevice, prefConfig.steamControllerEmulation); mBluetoothDevices.put(bluetoothDevice, device); device.start(); } @@ -205,6 +209,10 @@ public void setListener(ControllerDriverListener listener) { } } } + + public void setPreferenceConfiguration(PreferenceConfiguration prefConfig) { + BluetoothDriverService.this.prefConfig = prefConfig; + } } public class BluetoothEventReceiver extends BroadcastReceiver { diff --git a/app/src/main/java/com/limelight/binding/input/driver/SteamController.java b/app/src/main/java/com/limelight/binding/input/driver/SteamController.java index ff6ef7bdd4..44901ee5cd 100644 --- a/app/src/main/java/com/limelight/binding/input/driver/SteamController.java +++ b/app/src/main/java/com/limelight/binding/input/driver/SteamController.java @@ -8,11 +8,14 @@ import android.os.Looper; import android.util.Log; +import android.widget.Toast; import androidx.annotation.Nullable; import androidx.annotation.RequiresPermission; import com.limelight.LimeLog; +import com.limelight.R; import com.limelight.nvstream.input.ControllerPacket; import com.limelight.nvstream.jni.MoonBridge; +import com.limelight.preferences.PreferenceConfiguration; import org.bouncycastle.util.encoders.Hex; import org.jspecify.annotations.NonNull; @@ -20,14 +23,22 @@ import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.*; +import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; import static android.bluetooth.BluetoothDevice.TRANSPORT_LE; +import static android.util.Log.VERBOSE; /** * The methods in this class are inspired by SDL's Steam Controller HID device: * HIDDeviceBLESteamController.java + *

+ * Additional resources: + *

*/ public class SteamController extends AbstractController { @@ -44,15 +55,21 @@ public class SteamController extends AbstractController { private static final int BLEIMUQuatChunk = 0x1000; // Feature Report Command Reference: https://github.com/torvalds/linux/blob/master/drivers/hid/hid-steam.c#L85 - private static final byte FEATURE_REPORT_ID = (byte)0xC0; - private static final byte CMD_CLEAR_DIGITAL_MAPPINGS = (byte)0x81; - private static final byte CMD_SET_SETTINGS_VALUES = (byte)0x87; + private static final int FEATURE_REPORT_ID = 0xC0; + private static final int FEATURE_REPORT_ID_FRAGMENT_START = 0x80; + private static final int FEATURE_REPORT_ID_FRAGMENT_END = 0xC1; private static final byte SETTING_LPAD_MODE = (byte)0x07; private static final byte SETTING_RPAD_MODE = (byte)0x08; private static final byte SETTING_RPAD_MARGIN = (byte)0x18; + private static final byte SETTING_LED_USER_BRIGHTNESS = (byte)0x2D; private static final byte SETTING_GYRO_MODE = (byte)0x30; - private static final short TRACKPAD_MODE_DISABLED = 0x07; + private static final short TRACKPAD_MODE_NONE = 0x07; + + // Accelerometer has 16 bit resolution and a range of +/- 2g + private static final float ACCEL_RES_PER_G = 16_384.0f; + // Gyroscope has 16 bit resolution and a range of +/- 2000 dps + private static final float GYRO_RES_PER_DPS = 16.0f; // Valve Corporation private static final int VALVE_USB_VID = 0x28DE; @@ -61,61 +78,150 @@ public class SteamController extends AbstractController { static final UUID inputCharacteristicD0G = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3"); static final UUID inputCharacteristicTriton = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3"); static final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3"); - private static final byte[] setGamepadModeCommand = new byte[] { (byte)0xC0, CMD_SET_SETTINGS_VALUES, 0x0C, // Length - SETTING_LPAD_MODE, 0x07, 0x00, // Disable cursor - SETTING_RPAD_MODE, 0x07, 0x00, // Disable mouse - SETTING_RPAD_MARGIN, 0x00, 0x00, // No margin - SETTING_GYRO_MODE, /*0x1F*/0x00, 0x00, // Disable gyro/accel - }; private final Callback mCallback; - public SteamController(ControllerDriverListener listener, BluetoothDriverService manager, int deviceId, BluetoothDevice device) { + private int lastReportId; + private final List reassembledReport = new LinkedList<>(); + + public SteamController(ControllerDriverListener listener, BluetoothDriverService manager, int deviceId, + BluetoothDevice device, PreferenceConfiguration.SteamControllerEmulation emulationMode) { super(deviceId, listener, VALVE_USB_VID, 0); - type = MoonBridge.LI_CTYPE_PS; + type = emulationMode == PreferenceConfiguration.SteamControllerEmulation.PS5 ? MoonBridge.LI_CTYPE_PS : MoonBridge.LI_CTYPE_XBOX; capabilities = MoonBridge.LI_CCAP_ANALOG_TRIGGERS | MoonBridge.LI_CCAP_RUMBLE | MoonBridge.LI_CCAP_TRIGGER_RUMBLE | - MoonBridge.LI_CCAP_ACCEL | MoonBridge.LI_CCAP_GYRO; + MoonBridge.LI_CCAP_BATTERY_STATE; + if (emulationMode == PreferenceConfiguration.SteamControllerEmulation.PS5) { + capabilities |= MoonBridge.LI_CCAP_ACCEL | MoonBridge.LI_CCAP_GYRO | MoonBridge.LI_CCAP_RGB_LED; + } buttonFlags = - ControllerPacket.A_FLAG | ControllerPacket.B_FLAG | ControllerPacket.X_FLAG | ControllerPacket.Y_FLAG | - ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG | - ControllerPacket.LB_FLAG | ControllerPacket.RB_FLAG | - ControllerPacket.LS_CLK_FLAG | ControllerPacket.RS_CLK_FLAG | - ControllerPacket.BACK_FLAG | ControllerPacket.PLAY_FLAG | ControllerPacket.SPECIAL_BUTTON_FLAG; + ControllerPacket.RS_CLK_FLAG | ControllerPacket.LS_CLK_FLAG | + ControllerPacket.RB_FLAG | ControllerPacket.LB_FLAG | + ControllerPacket.Y_FLAG | ControllerPacket.B_FLAG | ControllerPacket.X_FLAG | ControllerPacket.A_FLAG | + ControllerPacket.UP_FLAG | ControllerPacket.RIGHT_FLAG | ControllerPacket.LEFT_FLAG | ControllerPacket.DOWN_FLAG | + ControllerPacket.BACK_FLAG | ControllerPacket.SPECIAL_BUTTON_FLAG | ControllerPacket.PLAY_FLAG; + //| ControllerPacket.PADDLE2_FLAG | ControllerPacket.PADDLE1_FLAG | ControllerPacket.TOUCHPAD_FLAG; mCallback = new Callback(manager, device); } - protected boolean handleRead(ByteBuffer buffer) { + protected void handleRead(ByteBuffer buffer) { buffer.order(ByteOrder.LITTLE_ENDIAN); - int version = Byte.toUnsignedInt(buffer.get()); // skip first byte - if (version != 0xC0) { - Log.d(TAG, "Unknown report version: " + version); - return false; + int reportId = Byte.toUnsignedInt(buffer.get()); + if (reportId < FEATURE_REPORT_ID || reportId == FEATURE_REPORT_ID_FRAGMENT_END) { + handleReportFragment(reportId, buffer); + return; + } + if (reportId != FEATURE_REPORT_ID) { + Log.d(TAG, "Unknown feature report ID: " + reportId); + return; } - int type = Byte.toUnsignedInt(buffer.get()) | Byte.toUnsignedInt(buffer.get()) << 8; - if (type == 4) { - // This is a special report that only contains IMU data. We can ignore it, as we get the same data in our regular reports. - byte[] imuData = new byte[buffer.remaining()]; - long sum = 0; - for (byte imuDatum : imuData) { - sum += Byte.toUnsignedInt(imuDatum); + int type = Short.toUnsignedInt(buffer.getShort()); + if (type == 0x04) { // Compare the whole short value on purpose + handleIdleEvent(buffer); + return; + } + + if ((byte)type == 0x05) { + handleBatteryEvent(type, buffer); + return; + } + + //Log.v(TAG, "handleRead type=" + type + " remaining=" + buffer.remaining()); + handleInputEvent(type, buffer); + handleGyroEvent(type, buffer); + } + + // Partial reports are described here: https://gist.github.com/tiehichi/aa714f976fab5f608996dc2a27ba94b3#input-report-notify-packet-format + private void handleReportFragment(int reportId, ByteBuffer buffer) { + if (reportId == FEATURE_REPORT_ID_FRAGMENT_START) { + resetReportFragmentation(); + lastReportId = reportId; + ByteBuffer reportFragment = ByteBuffer.allocate(buffer.capacity()).order(ByteOrder.LITTLE_ENDIAN); + reportFragment.put((byte)FEATURE_REPORT_ID); + reportFragment.put(buffer); + reassembledReport.add(reportFragment); + return; + } + + if (reportId != lastReportId + 1 && reportId != FEATURE_REPORT_ID_FRAGMENT_END) { + Log.d(TAG, "Received out-of-order report fragment, expected report ID " + (lastReportId + 1) + " but got " + reportId); + resetReportFragmentation(); + return; + } + + lastReportId = reportId; + ByteBuffer reportFragment = ByteBuffer.allocate(buffer.remaining()).order(ByteOrder.LITTLE_ENDIAN); + reportFragment.put(buffer); + reassembledReport.add(reportFragment); + + if (reportId == FEATURE_REPORT_ID_FRAGMENT_END) { + int totalSize = 0; + for (ByteBuffer fragment : reassembledReport) { + totalSize += fragment.capacity(); } - if (sum != 0) { - Log.d(TAG, "Received non-empty IMU-only report, ignoring. Data: " + Hex.toHexString(imuData)); + ByteBuffer fullReport = ByteBuffer.allocate(totalSize).order(ByteOrder.LITTLE_ENDIAN); + for (ByteBuffer fragment : reassembledReport) { + fragment.rewind(); + fullReport.put(fragment); } + fullReport.rewind(); + handleRead(fullReport); + resetReportFragmentation(); + } + } - return true; + private void resetReportFragmentation() { + lastReportId = 0; + reassembledReport.clear(); + } + + private void handleIdleEvent(ByteBuffer buffer) { + byte[] payload = new byte[buffer.remaining()]; + buffer.get(payload); + + // This is a special keep-alive report that should be otherwise empty, but check just in case. + long sum = 0; + for (byte imuDatum : payload) { + sum += Byte.toUnsignedInt(imuDatum); + } + if (sum != 0) { + Log.d(TAG, "Received non-empty keep-alive report, ignoring. Payload: " + Arrays.toString(payload)); + } + + } + + private void handleBatteryEvent(int type, ByteBuffer buffer) { + if ((type >> 8) != 0x55) { + Log.d(TAG, "Unknown battery report type: " + Integer.toHexString(type)); + return; + } + + buffer.get(); // Skip (unknown) status flags + long sequenceNumber = Integer.toUnsignedLong(buffer.getInt()); // Skip sequence number + long alwaysZero = Integer.toUnsignedLong(buffer.getInt()); + if (alwaysZero != 0) { + Log.d(TAG, "Received battery report with non-zero reserved field (was " + alwaysZero + "), this is unusual."); } + int voltage = Short.toUnsignedInt(buffer.getShort()); // Voltage in mV + // Estimate 3.3V .. 100%, 2.5V .. 0%: https://batteryskills.com/aa-battery-voltage-chart/ + byte percentage = (byte)Math.max(Math.min(100, ((voltage - 2500.0) / (3300.0 - 2500.0)) * 100.0), 0); + //Log.v(TAG, "Battery report " + sequenceNumber + ": " + voltage + "mV, " + percentage + "%"); + reportBatteryState(MoonBridge.LI_BATTERY_STATE_DISCHARGING, percentage); + } + + private void handleInputEvent(int type, ByteBuffer buffer) { + boolean inputReceived = false; if((type & BLEButtonChunk1) != 0) { byte[] buttons = new byte[3]; buffer.get(buttons); long b = Byte.toUnsignedLong(buttons[0]) | Byte.toUnsignedLong(buttons[1]) << 8 | Byte.toUnsignedLong(buttons[2]) << 16; - setButtonFlag(ControllerPacket.RS_CLK_FLAG, (int) (b & 0x00000001)); - setButtonFlag(ControllerPacket.LS_CLK_FLAG, (int) (b & 0x00000002)); + // Set stick click flags both for back paddle click and actual stick click state + setButtonFlag(ControllerPacket.RS_CLK_FLAG, (int) ((b & 0x00010000) | (b & 0x00040000))); + setButtonFlag(ControllerPacket.LS_CLK_FLAG, (int) ((b & 0x00008000) | (b & 0x00400000))); setButtonFlag(ControllerPacket.RB_FLAG, (int) (b & 0x00000004)); setButtonFlag(ControllerPacket.LB_FLAG, (int) (b & 0x00000008)); @@ -134,75 +240,111 @@ protected boolean handleRead(ByteBuffer buffer) { setButtonFlag(ControllerPacket.SPECIAL_BUTTON_FLAG, (int) (b & 0x00002000)); setButtonFlag(ControllerPacket.PLAY_FLAG, (int) (b & 0x00004000)); + if ((b & 0x00000001) != 0) { + rightTrigger = 1.0f; + } + if ((b & 0x00000002) != 0) { + leftTrigger = 1.0f; + } + + /*setButtonFlag(ControllerPacket.PADDLE2_FLAG, (int) (b & 0x00008000)); + setButtonFlag(ControllerPacket.PADDLE1_FLAG, (int) (b & 0x00010000)); + setButtonFlag(ControllerPacket.TOUCHPAD_FLAG, (int) (b & 0x00020000));*/ + Log.d(TAG, "Buttons: " + Long.toBinaryString(b)); + inputReceived = true; } if((type & BLEButtonChunk2) != 0) { int left = Byte.toUnsignedInt(buffer.get()); int right = Byte.toUnsignedInt(buffer.get()); - Log.d(TAG, "Triggers: "+left+" | "+right); - leftTrigger = left/255.0f; - rightTrigger = right/255.0f; + Log.d(TAG, "Triggers: " + left + " | " + right); + leftTrigger = left / 255.0f; + rightTrigger = right / 255.0f; + + inputReceived = true; } if((type & BLEButtonChunk3) != 0) { byte[] buttons = new byte[3]; buffer.get(buttons); + + inputReceived = true; } if((type & BLELeftJoystickChunk) != 0) { int x = buffer.getShort(); int y = ~buffer.getShort(); - Log.d(TAG, "Joystick: "+x+" | "+y); + Log.d(TAG, "Joystick: " + x + " | " + y); leftStickX = x / (float)Short.MAX_VALUE; leftStickY = y / (float)Short.MAX_VALUE; + + inputReceived = true; } if((type & BLELeftTrackpadChunk) != 0) { int x = buffer.getShort(); int y = ~buffer.getShort(); - //Log.d(TAG, "IGNORED Left Trackpad: "+x+" | "+y); + + Log.v(TAG, "IGNORED Left Trackpad: " + x + " | " + y); + inputReceived = true; } if((type & BLERightTrackpadChunk) != 0) { int x = buffer.getShort(); int y = ~buffer.getShort(); - Log.d(TAG, "Right Pad: "+x+" | "+y); - rightStickX = x / (float)Short.MAX_VALUE; - rightStickY = y / (float)Short.MAX_VALUE; - } - if((type & BLEIMUAccelChunk) != 0) { - accelX = buffer.getShort() / (float)Short.MAX_VALUE; - accelY = buffer.getShort() / (float)Short.MAX_VALUE; - accelZ = buffer.getShort() / (float)Short.MAX_VALUE; - Log.d(TAG, "Accel: "+accelX+" | "+accelY+" | "+accelZ); - } - if((type & BLEIMUGyroChunk) != 0) { - gyroX = buffer.getShort() / (float)Short.MAX_VALUE; - gyroY = buffer.getShort() / (float)Short.MAX_VALUE; - gyroZ = buffer.getShort() / (float)Short.MAX_VALUE; - Log.d(TAG, "Gyro: "+gyroX+" | "+gyroY+" | "+gyroZ); - } - if((type & BLEIMUQuatChunk) != 0) { + Log.d(TAG, "Right Pad: " + x + " | " + y); + rightStickX = x / (float) Short.MAX_VALUE; + rightStickY = y / (float) Short.MAX_VALUE; + + inputReceived = true; + } + + if (inputReceived) { + reportInput(); + } + } + + private void handleGyroEvent(int type, ByteBuffer buffer) { + boolean gyroReceived = false; + if ((type & BLEIMUAccelChunk) != 0) { + accelX = buffer.getShort() / ACCEL_RES_PER_G; + accelY = buffer.getShort() / ACCEL_RES_PER_G; + accelZ = buffer.getShort() / ACCEL_RES_PER_G; + + //Log.d(TAG, "Accel: " + accelX + " | " + accelY + " | " + accelZ); + gyroReceived = true; + } + if ((type & BLEIMUGyroChunk) != 0) { + gyroX = buffer.getShort() / GYRO_RES_PER_DPS; + gyroY = buffer.getShort() / GYRO_RES_PER_DPS; + gyroZ = buffer.getShort() / GYRO_RES_PER_DPS; + + //Log.d(TAG, "Gyro: " + gyroX + " | " + gyroY + " | " + gyroZ); + gyroReceived = true; + } + if ((type & BLEIMUQuatChunk) != 0) { int w = buffer.getShort(); int x = buffer.getShort(); int y = buffer.getShort(); - int z = 0;//buffer.getShort(); - Log.d(TAG, "IGNORED Gyro Quat: "+w+" | "+x+" | "+y+" | "+z); - } + int z = buffer.getShort(); - reportInput(); + //Log.v(TAG, "IGNORED Gyro Quat: " + w + " | " + x + " | " + y + " | " + z); + gyroReceived = true; + } - return true; + if (gyroReceived) { + reportMotion(); + } } @Override @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) public boolean start() { + LimeLog.info(TAG + ": Starting Steam Controller with address " + mCallback.mDevice.getAddress()); return mCallback.start(); } @Override @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) public void stop() { - // Stop rumbling before closing connection - mCallback.rumble((short) 0, (short) 0); - + LimeLog.info(TAG + ": Stopping Steam Controller with address " + mCallback.mDevice.getAddress()); + resetReportFragmentation(); mCallback.close(); } @@ -212,24 +354,45 @@ public int getProductId() { return mCallback.getProductId(); } + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void setMotionEventState(byte motionType, short reportRateHz) { + LimeLog.info(TAG + ": Enabling gyro with motionType=" + motionType + ", reportRateHz=" + reportRateHz); + if (reportRateHz > 0) { + mCallback.enableGyroscope(); + } else { + mCallback.disableGyroscope(); + } + } + + @Override + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void setControllerLED(byte r, byte g, byte b) { + // The Steam Controller doesn't support setting specific RGB values for its LED, but it does support setting the overall brightness of the LED. + int brightness = Math.max(Byte.toUnsignedInt(r), Math.max(Byte.toUnsignedInt(g), Byte.toUnsignedInt(b))); + brightness = (int)(brightness / 255.0 * 100); + LimeLog.info(TAG + ": Setting LED brightness to " + brightness + "%"); + mCallback.setLEDBrightness((byte)brightness); + } + @Override @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) public void rumble(short lowFreqMotor, short highFreqMotor) { - // TODO: Implement rumble - Log.d(TAG, "Rumbling imaginatively: lowFreq=" + lowFreqMotor + " highFreq=" + highFreqMotor); - //mCallback.rumble(lowFreqMotor, highFreqMotor); + Log.d(TAG, "Rumbling: lowFreq=" + lowFreqMotor + ", highFreq=" + highFreqMotor); + mCallback.rumble(lowFreqMotor, highFreqMotor); } @Override @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) public void rumbleTriggers(short leftTrigger, short rightTrigger) { - Log.d(TAG, "Rumbling triggers imaginatively: leftTrigger=" + leftTrigger + " rightTrigger=" + rightTrigger); - //mCallback.rumble(leftTrigger, rightTrigger); + Log.d(TAG, "Rumbling triggers: leftTrigger=" + leftTrigger + ", rightTrigger=" + rightTrigger); + mCallback.rumble(leftTrigger, rightTrigger); } @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) public void reconnect() { mCallback.reconnect(); + resetReportFragmentation(); } private class Callback extends BluetoothGattCallback implements Closeable { @@ -350,30 +513,34 @@ public void onServicesDiscovered(BluetoothGatt gatt, int status) { } @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) - private boolean probeService(SteamController controller) { + private void probeService(SteamController controller) { if (isRegistered()) { - return true; + return; } if (!mIsConnected) { - return false; + return; } - LimeLog.info("probeService controller=" + controller); + Log.d(TAG, "probeService controller=" + controller); for (BluetoothGattService service : mGatt.getServices()) { if (service.getUuid().equals(steamControllerService)) { - LimeLog.info("Found Valve steam controller service " + service.getUuid()); + LimeLog.info(TAG + ": Found Valve steam controller service " + service.getUuid()); for (BluetoothGattCharacteristic chr : service.getCharacteristics()) { boolean bShouldStartNotifications = false; if (chr.getUuid().equals(inputCharacteristicTriton)) { - LimeLog.info("Found Triton input characteristic"); + LimeLog.info(TAG + ": Found Triton input characteristic"); mProductId = TRITON_BLE_PID; - bShouldStartNotifications = true; + // TODO: Support Steam Controller 2 + //bShouldStartNotifications = true; + + Toast.makeText(mManager, R.string.toast_steam_controller_unsupported, Toast.LENGTH_LONG).show(); + mHandler.post(SteamController.this::stop); } else if (chr.getUuid().equals(inputCharacteristicD0G)) { - LimeLog.info("Found D0G input characteristic"); + LimeLog.info(TAG + ": Found D0G input characteristic"); mProductId = D0G_BLE2_PID; bShouldStartNotifications = true; } else { @@ -388,7 +555,7 @@ private boolean probeService(SteamController controller) { reportId -= 0x35; if (reportId >= 0x80) { // This is a Triton output report characteristic that we need to care about. - LimeLog.info("Found Triton output report 0x" + Integer.toString(reportId, 16)); + LimeLog.info(TAG + ": Found Triton output report 0x" + Integer.toString(reportId, 16)); mOutputReportChars.put(reportId, chr); } } @@ -406,7 +573,7 @@ private boolean probeService(SteamController controller) { } } } - return true; + return; } } @@ -417,8 +584,6 @@ private boolean probeService(SteamController controller) { mGatt.disconnect(); mGatt = connectGatt(); } - - return false; } @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) @@ -442,8 +607,6 @@ public void onCharacteristicRead(@NonNull BluetoothGatt gatt, BluetoothGattChara if (characteristic.getUuid().equals(reportCharacteristic)) { //mManager.HIDDeviceReportResponse(getId(), characteristic.getValue()); - // TODO: Report features - Log.v(TAG, "Report characteristic read: " + Hex.toHexString(characteristic.getValue())); } finishCurrentGattOperation(); @@ -485,7 +648,7 @@ private void executeNextGattOperation() { mHandler.post(() -> { synchronized (mOperations) { if (mCurrentOperation == null) { - LimeLog.warning("Current operation null in executor?"); + Log.w(TAG, "Current operation null in executor?"); return; } @@ -503,7 +666,7 @@ public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristi if (characteristic.getUuid().equals(reportCharacteristic)) { // Only register controller with the native side once it has been fully configured if (!isRegistered()) { - LimeLog.info("Registering Steam Controller with ID: " + getIdentifier()); + LimeLog.info(TAG + ": Registering Steam Controller with ID: " + getIdentifier()); //mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true); setRegistered(); notifyDeviceAdded(); @@ -531,31 +694,27 @@ public void onCharacteristicChanged(@NonNull BluetoothGatt gatt, BluetoothGattCh @Override public void onDescriptorRead(@NonNull BluetoothGatt gatt, @NonNull BluetoothGattDescriptor descriptor, int status, byte @NonNull [] value) { - Log.v(TAG, "onDescriptorRead status=" + status); + //Log.v(TAG, "onDescriptorRead status=" + status); } @Override @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) 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()); + //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid()); if (chr.getUuid().equals(getInputCharacteristic())) { BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic); - if (reportChr != null) { + if (reportChr != null && !isRegistered()) { if (getProductId() == TRITON_BLE_PID) { // For Triton we just mark things registered. - LimeLog.info("Registering Triton Steam Controller with ID: " + getControllerId()); + LimeLog.info(TAG + ": Registering Triton Steam Controller with ID: " + getControllerId()); //mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true); setRegistered(); } else { // For the original controller, we need to manually enter Valve mode. - LimeLog.info("Writing report characteristic to enter valve mode"); - /*reportChr.setValue(clearMappingsCommand); - gatt.writeCharacteristic(reportChr); - reportChr.setValue(enterValveMode); - gatt.writeCharacteristic(reportChr);*/ - enableLizardMode(); + Log.d(TAG, "Writing report characteristic to enter valve mode"); + enterLizardMode(); } } } @@ -583,9 +742,11 @@ private int getProductId() { @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) public void reconnect() { - if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) { + if (getConnectionState() != BluetoothProfile.STATE_CONNECTED && mGatt != null) { mGatt.disconnect(); mGatt = connectGatt(); + } else { + start(); } } @@ -607,41 +768,97 @@ private int getConnectionState() { return btManager.getConnectionState(mDevice, BluetoothProfile.GATT); } - public void enableLizardMode() { + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void enterLizardMode() { + // Logic inspired by: + // https://github.com/ricardoquesada/bluepad32/blob/main/src/components/bluepad32/parser/uni_hid_parser_steam.c#L76-L91 + // Disable esc, enter, cursor sendReportCommand(ReportCommand.CLEAR_DIGITAL_MAPPINGS, null); // Disable mouse - ByteBuffer settingsData = ByteBuffer.allocate(3).order(ByteOrder.LITTLE_ENDIAN); + ByteBuffer settingsData = ByteBuffer.allocate(6).order(ByteOrder.LITTLE_ENDIAN); settingsData.put(SETTING_LPAD_MODE); - settingsData.putShort(TRACKPAD_MODE_DISABLED); + settingsData.putShort(TRACKPAD_MODE_NONE); settingsData.put(SETTING_RPAD_MODE); - settingsData.putShort(TRACKPAD_MODE_DISABLED); + settingsData.putShort(TRACKPAD_MODE_NONE); sendReportCommand(ReportCommand.SET_SETTINGS_VALUES, settingsData); } - public void disableLizardMode() { + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void exitLizardMode() { // Enable esc, enter, cursors sendReportCommand(ReportCommand.SET_DEFAULT_DIGITAL_MAPPINGS, null); // Reset settings sendReportCommand(ReportCommand.LOAD_DEFAULT_SETTINGS, null); } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void enableGyroscope() { + ByteBuffer settingsData = ByteBuffer.allocate(3).order(ByteOrder.LITTLE_ENDIAN); + settingsData.put(SETTING_GYRO_MODE); + settingsData.putShort((short)(GyroMode.SEND_RAW_ACCEL.getValue() | GyroMode.SEND_RAW_GYRO.getValue())); + sendReportCommand(ReportCommand.SET_SETTINGS_VALUES, settingsData); + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void disableGyroscope() { + ByteBuffer settingsData = ByteBuffer.allocate(3).order(ByteOrder.LITTLE_ENDIAN); + settingsData.put(SETTING_GYRO_MODE); + settingsData.putShort(GyroMode.OFF.getValue()); + sendReportCommand(ReportCommand.SET_SETTINGS_VALUES, settingsData); + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + public void setLEDBrightness(byte brightnessPercent) { + if (brightnessPercent < 0 || brightnessPercent > 100) { + throw new IllegalArgumentException("Brightness percentage must be between 0 and 100"); + } + + ByteBuffer settingsData = ByteBuffer.allocate(3).order(ByteOrder.LITTLE_ENDIAN); + settingsData.put(SETTING_LED_USER_BRIGHTNESS); + settingsData.putShort(brightnessPercent); + sendReportCommand(ReportCommand.SET_SETTINGS_VALUES, settingsData); + } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) public void rumble(short leftSpeed, short rightSpeed) { // Modeled after the rumble command in Linux's hid-steam driver: // https://github.com/torvalds/linux/blob/master/drivers/hid/hid-steam.c#L514-L555 - ByteBuffer rumbleData = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN); - rumbleData.put((byte)0) // Amplitude low byte + // However, this doesn't seem to work + /*ByteBuffer rumbleData = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN) + .put((byte)0) // Amplitude low byte .put((byte)0) // Amplitude high byte .putShort(leftSpeed) .putShort(rightSpeed) .put((byte)2) // Left gain .put((byte)0); // Right gain + sendReportCommand(ReportCommand.TRIGGER_RUMBLE, rumbleData);*/ - sendReportCommand(ReportCommand.TRIGGER_RUMBLE, rumbleData); + hapticPulse(Trigger.LEFT, leftSpeed, (byte) 2); + hapticPulse(Trigger.RIGHT, rightSpeed, (byte) 0); } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private void hapticPulse(Trigger trigger, short onTime, byte gain) { + short offTime = 0; + short pulses = 0; + if (onTime > 0) { + offTime = (short)(0xFFFF - onTime); + pulses = (short)0xFFFF; // Practically infinite for our purposes until the next rumble command arrives + } + + ByteBuffer rumbleData = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN) + .put(trigger.getValue()) // Trigger index + .putShort(onTime) // Time on in µs + .putShort(offTime) // Time off in µs + .putShort(pulses) // Number of pulses + .put(gain); // Gain in decibels, ranging from -24 to +6 + Log.v(TAG, "Rumbling trigger " + trigger + ": onTime=" + onTime + "µs, offTime=" + offTime + "µs"); + sendReportCommand(ReportCommand.TRIGGER_HAPTIC_PULSE, rumbleData); + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) private void sendReportCommand(ReportCommand reportCommand, @Nullable ByteBuffer payload) { int payloadLength = 0; if (payload != null) { @@ -650,13 +867,20 @@ private void sendReportCommand(ReportCommand reportCommand, @Nullable ByteBuffer } byte[] command = new byte[3 + payloadLength]; - command[0] = FEATURE_REPORT_ID; + command[0] = (byte)FEATURE_REPORT_ID; command[1] = reportCommand.getValue(); - command[2] = (byte)(payloadLength + 1); // Length includes the length itself + command[2] = (byte) payloadLength; if (payload != null) { payload.get(command, 3, payloadLength); } + if (Log.isLoggable(TAG, Log.VERBOSE)) { + String[] bytes = new String[command.length]; + for (int i = 0; i < command.length; i++) { + bytes[i] = Hex.toHexString(command, i, 1); + } + Log.v(TAG, "Sending report command " + reportCommand.name() + ", command=" + Arrays.toString(bytes)); + } queueGattOperation(GattOperation.writeCharacteristic(mGatt, reportCharacteristic, command)); } @@ -665,17 +889,46 @@ private void sendReportCommand(ReportCommand reportCommand, @Nullable ByteBuffer public void close() { BluetoothGatt g = mGatt; if (g != null) { - if (getProductId() == D0G_BLE2_PID) { - disableLizardMode(); + if (mIsConnected) { + // Reset settings potentially changed while using the controller + rumble((short)0, (short)0); + setLEDBrightness((byte)100); + + if (getProductId() == D0G_BLE2_PID) { + exitLizardMode(); + } + + mHandler.post(this::finishClosing); + } else { + finishClosing(); + } + } else { + resetState(); + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + private void finishClosing() { + synchronized (mOperations) { + if (mIsConnected && !mOperations.isEmpty()) { + // Wait for pending operations to finish before closing GATT, otherwise we might run into issues with pending operations trying to access a closed GATT. + mHandler.post(this::finishClosing); + return; } + } + BluetoothGatt g = mGatt; + if (g != null) { g.disconnect(); g.close(); mGatt = null; } + resetState(); + } + + private void resetState() { mIsRegistered = false; mIsConnected = false; - mOperations.clear(); } private boolean isRegistered() { @@ -732,7 +985,7 @@ public void run() { chr = getCharacteristic(mUuid); //Log.v(TAG, "Reading characteristic " + chr.getUuid()); if (!mGatt.readCharacteristic(chr)) { - LimeLog.severe("Unable to read characteristic " + mUuid.toString()); + LimeLog.severe(TAG + ": Unable to read characteristic " + mUuid.toString()); mResult = false; break; } @@ -743,7 +996,7 @@ public void run() { //Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value)); chr.setValue(mValue); if (!mGatt.writeCharacteristic(chr)) { - LimeLog.severe("Unable to write characteristic " + mUuid.toString()); + LimeLog.severe(TAG + ": Unable to write characteristic " + mUuid.toString()); mResult = false; break; } @@ -762,7 +1015,7 @@ public void run() { } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) { value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE; } else { - LimeLog.severe("Unable to start notifications on input characteristic"); + LimeLog.severe(TAG + ": Unable to start notifications on input characteristic"); mResult = false; return; } @@ -770,7 +1023,7 @@ public void run() { mGatt.setCharacteristicNotification(chr, true); cccd.setValue(value); if (!mGatt.writeDescriptor(cccd)) { - LimeLog.severe("Unable to write descriptor " + mUuid.toString()); + LimeLog.severe(TAG + ": Unable to write descriptor " + mUuid.toString()); mResult = false; return; } @@ -809,6 +1062,7 @@ private enum ReportCommand { SET_DEFAULT_DIGITAL_MAPPINGS((byte)0x85), SET_SETTINGS_VALUES((byte)0x87), LOAD_DEFAULT_SETTINGS((byte)0x8E), + TRIGGER_HAPTIC_PULSE((byte)0x8F), TRIGGER_RUMBLE((byte)0xEB); private final byte value; @@ -820,4 +1074,54 @@ public byte getValue() { return value; } } + + private enum GyroMode { + /** + * No gyroscope input whatsoever. + */ + OFF(0x00), + /** + * Simulates steering wheel motions on the left trackpad. + */ + STEERING(0x01), + /** + * Simulates tilting motions (like a fishing rod) on the left trackpad where the X axis always stays 0. + */ + TILT(0x02), + /** + * Sends quaternion data from the gyroscope. + */ + SEND_ORIENTATION(0x04), + /** + * Sends raw acceleration data from the gyroscope. + */ + SEND_RAW_ACCEL(0x08), + /** + * Sends raw gyro data from the gyroscope. + */ + SEND_RAW_GYRO(0x10); + + private final short value; + GyroMode(int value) { + this.value = (short)value; + } + + public short getValue() { + return value; + } + } + + private enum Trigger { + LEFT(0x01), + RIGHT(0x00); + + private final byte value; + Trigger(int value) { + this.value = (byte)value; + } + + public byte getValue() { + return value; + } + } } diff --git a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java index f207171740..f431a7935c 100755 --- a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java +++ b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java @@ -6,6 +6,7 @@ import android.os.Build; import android.view.Display; +import com.limelight.R; import com.limelight.nvstream.jni.MoonBridge; import com.limelight.profiles.ProfilesManager; @@ -30,6 +31,11 @@ public enum AnalogStickForScrolling { LEFT } + public enum SteamControllerEmulation { + PS5, + XBOX + } + public static final String CUSTOM_BITRATE_PREF_STRING = "edit_diy_bitrate"; public static final String CUSTOM_REFRESH_RATE_PREF_STRING = "custom_refresh_rate"; public static final String CUSTOM_RESOLUTION_PREF_STRING = "edit_diy_w_h"; @@ -61,7 +67,8 @@ public enum AnalogStickForScrolling { private static final String MULTI_CONTROLLER_PREF_STRING = "checkbox_multi_controller"; static final String AUDIO_CONFIG_PREF_STRING = "list_audio_config"; private static final String USB_DRIVER_PREF_SRING = "checkbox_usb_driver"; - private static final String BLUETOOTH_DRIVER_PREF_SRING = "checkbox_bluetooth_driver"; + private static final String BLUETOOTH_DRIVER_PREF_STRING = "checkbox_bluetooth_driver"; + private static final String STEAMCONTROLLER_EMULATION_PREF_STRING = "list_steamcontroller_emulation"; private static final String VIDEO_FORMAT_PREF_STRING = "video_format"; private static final String ONSCREEN_CONTROLLER_PREF_STRING = "checkbox_show_onscreen_controls"; private static final String CHECKBOX_HIDE_OSC_WHEN_HAS_GAMEPAD = "checkbox_hide_osc_when_has_gamepad"; @@ -157,6 +164,7 @@ public enum AnalogStickForScrolling { private static final boolean DEFAULT_MULTI_CONTROLLER = true; private static final boolean DEFAULT_USB_DRIVER = true; private static final boolean DEFAULT_BLUETOOTH_DRIVER = false; + private static final String DEFAULT_STEAMCONTROLLER_EMULATION = "xbox"; private static final String DEFAULT_VIDEO_FORMAT = "auto"; private static final boolean DEFAULT_ONSCREEN_CONTROLLER = false; @@ -251,6 +259,7 @@ public enum AnalogStickForScrolling { public String language; public int renderMode; public boolean smallIconMode, multiController, usbDriver, bluetoothDriver, flipFaceButtons; + public SteamControllerEmulation steamControllerEmulation; public boolean onscreenController; public boolean hideOSCWhenHasGamepad; public boolean enableBatteryReport; @@ -682,6 +691,22 @@ else if (str.equals("left")) { } } + private static SteamControllerEmulation getSteamControllerEmulationValue(Context context) { + SharedPreferences prefs = ProfilesManager.getInstance().getOverlayingSharedPreferences(context); + + String str = prefs.getString(STEAMCONTROLLER_EMULATION_PREF_STRING, DEFAULT_STEAMCONTROLLER_EMULATION); + if (str.equals("ps5")) { + return SteamControllerEmulation.PS5; + } + else if (str.equals("xbox")) { + return SteamControllerEmulation.XBOX; + } + else { + // Should never get here + return SteamControllerEmulation.XBOX; + } + } + public static void resetStreamingSettings(Context context) { // We consider resolution, FPS, bitrate, HDR, and video format as "streaming settings" here SharedPreferences prefs = ProfilesManager.getInstance().getOverlayingSharedPreferences(context); @@ -887,7 +912,8 @@ else if (audioConfig.equals("51")) { config.smallIconMode = prefs.getBoolean(SMALL_ICONS_PREF_STRING, getDefaultSmallMode(context)); config.multiController = prefs.getBoolean(MULTI_CONTROLLER_PREF_STRING, DEFAULT_MULTI_CONTROLLER); config.usbDriver = prefs.getBoolean(USB_DRIVER_PREF_SRING, DEFAULT_USB_DRIVER); - config.bluetoothDriver = prefs.getBoolean(BLUETOOTH_DRIVER_PREF_SRING, DEFAULT_BLUETOOTH_DRIVER); + config.bluetoothDriver = prefs.getBoolean(BLUETOOTH_DRIVER_PREF_STRING, DEFAULT_BLUETOOTH_DRIVER); + config.steamControllerEmulation = getSteamControllerEmulationValue(context); config.fullScreen = prefs.getBoolean(FULL_SCREEN_PREF_STRING, DEFAULT_FULL_SCREEN); String renderMode = prefs.getString("render_mode_list", "0"); diff --git a/app/src/main/java/com/limelight/preferences/StreamSettings.java b/app/src/main/java/com/limelight/preferences/StreamSettings.java index d6773ec16e..cc5089c011 100755 --- a/app/src/main/java/com/limelight/preferences/StreamSettings.java +++ b/app/src/main/java/com/limelight/preferences/StreamSettings.java @@ -389,6 +389,7 @@ public void initializePreferences() { PreferenceCategory category = (PreferenceCategory) findPreference("category_gamepad_settings"); category.removePreference(findPreference("checkbox_bluetooth_driver")); + category.removePreference(findPreference("steamcontroller_emulation")); } // Remove PiP mode on devices pre-Oreo, where the feature is not available (some low RAM devices), diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 763ed88197..489eb2b445 100755 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -54,6 +54,15 @@ 71 + + @string/steamcontroller_emulation_xbox + @string/steamcontroller_emulation_ps5 + + + xbox + ps5 + + Default diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ab26931c4f..9d410aa92d 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -236,6 +236,8 @@ Use Artemis\'s USB driver for all supported gamepads, even if native Xbox/Nintendo Pro controller support is present Steam Controller Bluetooth gamepad driver Enables a built-in Bluetooth driver for the Steam Controller + Steam Controller emulation mode + Set the controller emulated by the Steam Controller:\nXbox shows buttons as A, B, X and Y\nDualSense enables gyroscope and LED support, but shows buttons as \u2715, \u25EF, \u25A1 and \u25B3 Mouse emulation via gamepad Long-press the Start button to toggle between gamepad and mouse mode Remember mouse mode @@ -331,6 +333,7 @@ Gamepad type may be changed due to motion sensor emulation + Steam Controller 2 is not supported yet 360p @@ -354,6 +357,9 @@ Prefer HEVC Prefer H.264 + Xbox One S + DualSense (PlayStation 5) + Video frame pacing Specify how to balance video latency and smoothness Warp Drive (Experimental) diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 73d2391529..b8c5932e09 100755 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -277,6 +277,15 @@ android:title="@string/title_checkbox_bt_driver" android:summary="@string/summary_checkbox_bt_driver" app:iconSpaceReserved="false" /> + Date: Tue, 3 Mar 2026 13:55:59 +0100 Subject: [PATCH 6/6] Added German translations --- app/src/main/res/values-de/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ae5b9ee0f1..d504a7df7d 100755 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -150,6 +150,10 @@ Aktiviert den eingebauten USB Treiber für Geräte die keine Xbox-Controller Unterstützung haben Android Controller Unterstützung überschreiben Erzwingt die Verwendung von Moonlights USB Treiber für alle Xbox kompatiblen Controller + Steam Controller Bluetooth-Treiber + Aktiviert den eingebauten Bluetooth-Treiber für den Steam Controller + Steam Controller Emulationsmodus + Setzt den vom Steam Controller emulierten Controller:\nXbox zeigt Tasten als A, B, X and Y\nDualSense aktiviert Gyroskop und LED-Unterstützung, zeigt aber Tasten als \u2715, \u25EF, \u25A1 und \u25B3 Maus Emulation via Controller Langes gedrückt halten der Start-Taste wechselt in den Maus Modus Vor- und Zurück-Tasten aktivieren @@ -282,6 +286,7 @@ Emuliere die Unterstützung von Gamepad-Bewegungssensoren Full Range Video erzwingen (Experimentell) Der Gamepad-Typ kann sich aufgrund der Bewegungssensor-Emulation ändern + Berechtigung verweigert. Steam Controller werden nicht funktionieren. Dies führt zu Detailverlusten in hellen und dunklen Bereichen, wenn Ihr Gerät Full Range Video Inhalte nicht richtig anzeigt. Verwendet die integrierten Bewegungssensoren Ihres Geräts, wenn Gamepad-Sensoren von Ihrem angeschlossenen Gamepad oder Ihrer Android-Version nicht unterstützt werden. \nHinweis: Die Aktivierung dieser Option kann dazu führen, dass Ihr Gamepad auf dem Host als PlayStation-Controller angezeigt wird.