Bluetooth VR controllers in mobile app

NOVASOLUTIONS.TECHNOLOGY is engaged in the development, support and maintenance of iOS, Android, PWA mobile applications. We have extensive experience and expertise in publishing mobile applications in popular markets like Google Play, App Store, Amazon, AppGallery and others.
Development and support of all types of mobile applications:
Information and entertainment mobile applications
News apps, games, reference guides, online catalogs, weather apps, fitness and health apps, travel apps, educational apps, social networks and messengers, quizzes, blogs and podcasts, forums, aggregators
E-commerce mobile applications
Online stores, B2B apps, marketplaces, online exchanges, cashback services, exchanges, dropshipping platforms, loyalty programs, food and goods delivery, payment systems.
Business process management mobile applications
CRM systems, ERP systems, project management, sales team tools, financial management, production management, logistics and delivery management, HR management, data monitoring systems
Electronic services mobile applications
Classified ads platforms, online schools, online cinemas, electronic service platforms, cashback platforms, video hosting, thematic portals, online booking and scheduling platforms, online trading platforms

These are just some of the types of mobile applications we work with, and each of them may have its own specific features and functionality, tailored to the specific needs and goals of the client.

Showing 1 of 1 servicesAll 1735 services
Bluetooth VR controllers in mobile app
Medium
~3-5 business days
FAQ
Our competencies:
Development stages
Latest works
  • image_mobile-applications_feedme_467_0.webp
    Development of a mobile application for FEEDME
    756
  • image_mobile-applications_xoomer_471_0.webp
    Development of a mobile application for XOOMER
    624
  • image_mobile-applications_rhl_428_0.webp
    Development of a mobile application for RHL
    1052
  • image_mobile-applications_zippy_411_0.webp
    Development of a mobile application for ZIPPY
    947
  • image_mobile-applications_affhome_429_0.webp
    Development of a mobile application for Affhome
    862
  • image_mobile-applications_flavors_409_0.webp
    Development of a mobile application for the FLAVORS company
    445

Implementing VR Controllers via Bluetooth in Mobile App

Mobile VR with Bluetooth controllers — Cardboard/Daydream era or modern solutions like Pico G3 with 3DoF tracking. In both cases, the task is same: get IMU data (accelerometer, gyroscope, magnetometer) and buttons from controller via BLE with minimum latency, convert to 3D position/orientation, and pass to engine rendering.

BLE Controller GATT Profile

Most VR controllers implement standard HID over GATT profile or custom GATT service for IMU data. For custom — need manufacturer documentation with characteristic UUIDs.

Typical VR controller GATT structure:

  • Service UUID 00001812-0000-1000-8000-00805f9b34fb (HID Service) or custom
  • Report Characteristic — input data: buttons + IMU (notify)
  • Battery Service 0000180f-0000-1000-8000-00805f9b34fb — charge level (read/notify)
class VRControllerGattClient(private val context: Context) {
    private var bluetoothGatt: BluetoothGatt? = null
    private val CONTROLLER_SERVICE_UUID = UUID.fromString("YOUR-CONTROLLER-UUID")
    private val IMU_CHARACTERISTIC_UUID = UUID.fromString("YOUR-IMU-CHAR-UUID")

    fun connect(device: BluetoothDevice) {
        // TRANSPORT_LE — explicitly specify BLE, not classic BT
        bluetoothGatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
    }

    private val gattCallback = object : BluetoothGattCallback() {
        override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
            if (newState == BluetoothProfile.STATE_CONNECTED) {
                gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
                gatt.discoverServices()
            }
        }

        override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
            val service = gatt.getService(CONTROLLER_SERVICE_UUID) ?: return
            val imuChar = service.getCharacteristic(IMU_CHARACTERISTIC_UUID) ?: return
            gatt.setCharacteristicNotification(imuChar, true)

            // Enable Client Characteristic Configuration Descriptor
            val descriptor = imuChar.getDescriptor(
                UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
            )
            descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
            gatt.writeDescriptor(descriptor)
        }

        override fun onCharacteristicChanged(
            gatt: BluetoothGatt,
            characteristic: BluetoothGattCharacteristic,
            value: ByteArray
        ) {
            parseControllerData(value)
        }
    }
}

requestConnectionPriority(CONNECTION_PRIORITY_HIGH) switches BLE connection interval from default 45ms to 7.5ms. Critical for VR: at 45ms input lag is noticeable discomfort, at 7.5ms imperceptible.

Parsing IMU Data and Sensor Fusion

Data from MEMS gyro and accelerometer — raw readings in manufacturer units. Needs calibration and sensor fusion for stable quaternion orientation.

data class ControllerState(
    val gyroX: Float, val gyroY: Float, val gyroZ: Float, // rad/s
    val accelX: Float, val accelY: Float, val accelZ: Float, // m/s²
    val buttons: Int, // button bitmask
    val trigger: Float, // analog trigger 0..1
    val timestamp: Long
)

fun parseControllerData(raw: ByteArray): ControllerState {
    val buffer = ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN)
    return ControllerState(
        gyroX = buffer.short.toFloat() / 32768f * GYRO_SCALE, // GYRO_SCALE in rad/s
        gyroY = buffer.short.toFloat() / 32768f * GYRO_SCALE,
        gyroZ = buffer.short.toFloat() / 32768f * GYRO_SCALE,
        accelX = buffer.short.toFloat() / 32768f * ACCEL_SCALE,
        accelY = buffer.short.toFloat() / 32768f * ACCEL_SCALE,
        accelZ = buffer.short.toFloat() / 32768f * ACCEL_SCALE,
        buttons = buffer.short.toInt(),
        trigger = (buffer.byte.toInt() and 0xFF) / 255f,
        timestamp = SystemClock.elapsedRealtimeNanos()
    )
}

For orientation — complementary filter (fast) or Madgwick/Mahony AHRS (more precise):

class MadgwickFilter(private val beta: Float = 0.1f) {
    private var q = floatArrayOf(1f, 0f, 0f, 0f) // orientation quaternion

    fun update(gx: Float, gy: Float, gz: Float,
               ax: Float, ay: Float, az: Float, dt: Float) {
        // Madgwick AHRS algorithm
        // Normalize accelerometer
        val norm = sqrt(ax * ax + ay * ay + az * az)
        if (norm == 0f) return
        // ... full algorithm implementation
        // Result: q[0..3] — current orientation quaternion
    }

    fun getQuaternion() = Quaternion(q[0], q[1], q[2], q[3])
}

beta = 0.1f is compromise between response speed and noise filtering. On quick movement increase to 0.3, on static decrease to 0.01.

VR Rendering Integration

On Android — pass controller data to Unity via AndroidJavaClass or directly to OpenXR via XR_EXT_hand_tracking-compatible plugin. For Godot — GodotAndroidPlugin with exposed methods.

Common mistake: pass controller data directly from BLE callback thread to render thread. Need thread-safe buffer:

// Atomic reference for latest controller state
private val latestState = AtomicReference<ControllerState?>()

override fun onCharacteristicChanged(..., value: ByteArray) {
    latestState.set(parseControllerData(value))
}

// From render thread (every frame)
fun pollControllerState(): ControllerState? = latestState.getAndSet(null)

Timeline

BLE connection to existing VR controller with ready GATT documentation, IMU parsing, Unity/Godot integration: 3–5 days. Development with GATT protocol reverse-engineering of unknown controller + custom sensor fusion: 1–2 weeks.