Skip to content

My GoPro 12 is rejecting any set commands. #860

@Michael-Sche

Description

@Michael-Sche

Hi,

I currently trying to integrate the gopro ble apis into my android app.

The connection in my code is successfully established and I can get successful responses on get (query) commands.

The issue I have is that no matter which set (control) commands I test the gopro always rejects it.

Based on the documentation I also tried claiming control of the camera (also in documenation) but that also fails.

I have attached my log(code will be put below)

Image

Is my code snippet correct ?

@SuppressLint("MissingPermission")
@OptIn(ExperimentalUnsignedTypes::class)
class GoProBleManager(private val context: Context) {

companion object {
    private const val TAG = "GoProBleManager"

    // Service UUID
    val GP_SERVICE_UUID: UUID = UUID.fromString("0000fea6-0000-1000-8000-00805f9b34fb")

    // Command UUIDs (For Actions like Set Time)
    val COMMAND_REQ_UUID: UUID = UUID.fromString("b5f90072-aa8d-11e3-9046-0002a5d5c51b")
    val COMMAND_RSP_UUID: UUID = UUID.fromString("b5f90073-aa8d-11e3-9046-0002a5d5c51b")

    // Query UUIDs (For asking "Are you ready?")
    val QUERY_REQ_UUID: UUID   = UUID.fromString("b5f90076-aa8d-11e3-9046-0002a5d5c51b")
    val QUERY_RSP_UUID: UUID   = UUID.fromString("b5f90077-aa8d-11e3-9046-0002a5d5c51b")

    // Setup UUIDs
    val WIFI_AP_PASSWORD_UUID: UUID = UUID.fromString("b5f90003-aa8d-11e3-9046-0002a5d5c51b")
    val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
}

private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val adapter: BluetoothAdapter? = bluetoothManager.adapter

// Channels for async callbacks
private val descriptorWriteChannel = Channel<Int>(Channel.UNLIMITED)
private val responseChannel = Channel<ByteArray>(Channel.UNLIMITED)

// We track the Connection State separately
private val connectionStateChannel = Channel<Int>(Channel.CONFLATED)

suspend fun connectAndSetTime(onStatus: (String) -> Unit): Boolean = withContext(Dispatchers.IO) {
    if (adapter == null || !adapter.isEnabled) return@withContext false

    var gatt: BluetoothGatt? = null
    try {
        // 1. CONNECT
        onStatus("Scanning...")
        val device = scanForGoPro() ?: return@withContext false

        onStatus("Connecting...")
        gatt = connectSafe(device)

        // 2. REQUEST HIGH PRIORITY (Fixes many "Busy" issues on Android)
        gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)

        // 3. PAIRING (Magic Read)
        onStatus("Pairing...")
        gatt.discoverServices()
        delay(1500)
        gatt.getService(GP_SERVICE_UUID)?.getCharacteristic(WIFI_AP_PASSWORD_UUID)?.let {
            gatt.readCharacteristic(it)
        }
        delay(500)

        // 4. SUBSCRIBE TO EVERYTHING
        onStatus("Subscribing...")
        enableAllNotificationsSequential(gatt)
        delay(500)

        // 5. DEBUG: CHECK IF CAMERA IS READY (Status 8)
        // This will tell us if the camera is busy encoding, in a menu, etc.
        onStatus("Checking Status...")
        val isReady = waitForSystemReady(gatt) { log ->
            Log.d(TAG, log)
            if(log.contains("Busy")) onStatus("Camera Busy... Waiting")
        }

        if (!isReady) {
            onStatus("Camera is BUSY (In Menu/Encoding?)")
            Log.e(TAG, "System Ready (Status 8) returned FALSE or TIMEOUT")
            // We continue anyway to try, but we know it will likely fail
        }

        // 6. SET TIME
        onStatus("Setting Time...")
        val timeCmd = getSetDateTimeCommand()
        val timeResult = sendCommand(gatt, timeCmd, COMMAND_REQ_UUID)

        if (timeResult) {
            onStatus("Success!")
            return@withContext true
        }

        onStatus("Failed (Busy)")
        return@withContext false

    } catch (e: Exception) {
        Log.e(TAG, "Fatal Error", e)
        return@withContext false
    } finally {
        Log.d(TAG, "Disconnecting...")
        try { gatt?.disconnect(); gatt?.close() } catch (_: Exception) {}
    }
}

// =========================================================================================
// QUERY LOGIC (The "Is It Ready?" Check)
// =========================================================================================

private suspend fun waitForSystemReady(gatt: BluetoothGatt, onLog: (String) -> Unit): Boolean {
    // We poll status ID 8 (System Ready)
    // Packet: Length 02, Command 12 (Get Status), Param 08 (System Ready ID)
    val query = ubyteArrayOf(0x02U, 0x12U, 0x08U)

    for (i in 1..5) {
        while(responseChannel.tryReceive().isSuccess) {} // Flush old data

        Log.d(TAG, "TX QUERY -> ${query.toHexString()}")
        writeSafe(gatt, QUERY_REQ_UUID, query.toByteArray())

        try {
            withTimeout(2000) {
                val rsp = responseChannel.receive() // Wait for notification on QUERY_RSP_UUID
                Log.d(TAG, "RX QUERY <- ${rsp.joinToString(":") { "%02X".format(it) }}")

                // Response Format: Length, ID(12), Error(00), ValLen(01), Value(01=Ready, 00=Busy)
                // Example Ready: 05:13:00:01:01 (Note: ID 12 response is usually 13)

                if (rsp.size >= 5) {
                    val value = rsp[4]
                    if (value == 0x01.toByte()) {
                        onLog("System is READY (Value 1)")
                        return@withTimeout true
                    } else {
                        onLog("System is BUSY (Value 0)")
                    }
                }
            }
        } catch (e: Exception) {
            Log.w(TAG, "Query Timeout")
        }
        delay(1000)
    }
    return false
}

// =========================================================================================
// BLE HELPERS
// =========================================================================================

private suspend fun sendCommand(gatt: BluetoothGatt, data: UByteArray, uuid: UUID): Boolean {
    while(responseChannel.tryReceive().isSuccess) {}

    Log.d(TAG, "TX CMD -> ${data.toHexString()}")
    writeSafe(gatt, uuid, data.toByteArray())

    return try {
        withTimeout(4000) {
            val response = responseChannel.receive()
            Log.d(TAG, "RX CMD <- ${response.joinToString(":") { "%02X".format(it) }}")

            // Check Status Byte (usually index 2 for commands)
            if (response.size > 2 && response[2] == 0x00.toByte()) return@withTimeout true
            false
        }
    } catch (e: Exception) {
        Log.e(TAG, "Command Timeout")
        false
    }
}

private fun writeSafe(gatt: BluetoothGatt, uuid: UUID, value: ByteArray) {
    val char = gatt.getService(GP_SERVICE_UUID)?.getCharacteristic(uuid) ?: return
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        gatt.writeCharacteristic(char, value, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
    } else {
        char.value = value
        char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
        gatt.writeCharacteristic(char)
    }
}

private suspend fun enableAllNotificationsSequential(gatt: BluetoothGatt) {
    val service = gatt.getService(GP_SERVICE_UUID) ?: return
    for (char in service.characteristics) {
        if ((char.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) {
            enableNotificationSafe(gatt, char)
        }
    }
}

private suspend fun enableNotificationSafe(gatt: BluetoothGatt, char: BluetoothGattCharacteristic) = suspendCancellableCoroutine { cont ->
    gatt.setCharacteristicNotification(char, true)
    val desc = char.getDescriptor(CCCD_UUID)
    if (desc == null) { cont.resume(Unit); return@suspendCancellableCoroutine }

    desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE

    val job = CoroutineScope(Dispatchers.IO).launch {
        try {
            withTimeout(2000) {
                descriptorWriteChannel.receive()
                if (cont.isActive) cont.resume(Unit)
            }
        } catch (e: Exception) { if (cont.isActive) cont.resume(Unit) }
    }

    val success = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        gatt.writeDescriptor(desc, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) == BluetoothStatusCodes.SUCCESS
    } else {
        gatt.writeDescriptor(desc)
    }

    if (!success) { job.cancel(); cont.resume(Unit) }
}

// =========================================================================================
// CALLBACKS
// =========================================================================================

private val gattCallback = object : BluetoothGattCallback() {
    override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
        connectionStateChannel.trySend(newState)
    }
    override fun onDescriptorWrite(gatt: BluetoothGatt?, descriptor: BluetoothGattDescriptor?, status: Int) {
        descriptorWriteChannel.trySend(status)
    }
    override fun onCharacteristicChanged(gatt: BluetoothGatt, char: BluetoothGattCharacteristic) {
        responseChannel.trySend(char.value)
    }
    override fun onCharacteristicChanged(gatt: BluetoothGatt, char: BluetoothGattCharacteristic, value: ByteArray) {
        responseChannel.trySend(value)
    }
}

// =========================================================================================
// UTILS
// =========================================================================================

private suspend fun scanForGoPro(): BluetoothDevice? = suspendCancellableCoroutine { cont ->
    val scanner = adapter?.bluetoothLeScanner
    val callback = object : android.bluetooth.le.ScanCallback() {
        override fun onScanResult(c: Int, r: android.bluetooth.le.ScanResult) {
            if (r.device.name?.contains("GoPro") == true) {
                scanner?.stopScan(this)
                if (cont.isActive) cont.resume(r.device)
            }
        }
        override fun onScanFailed(e: Int) { if (cont.isActive) cont.resume(null) }
    }
    scanner?.startScan(callback)
}

private suspend fun connectSafe(device: BluetoothDevice): BluetoothGatt = suspendCancellableCoroutine { cont ->
    val gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
    } else {
        device.connectGatt(context, false, gattCallback)
    }
    val job = CoroutineScope(Dispatchers.IO).launch {
        try {
            withTimeout(10000) {
                while (true) {
                    val state = connectionStateChannel.receive()
                    if (state == BluetoothProfile.STATE_CONNECTED) {
                        if (cont.isActive) cont.resume(gatt)
                        break
                    }
                }
            }
        } catch (e: Exception) { /* timeout */ }
    }
}

private fun getSetDateTimeCommand(): UByteArray {
    val c = Calendar.getInstance()
    val bb = ByteBuffer.allocate(9).order(ByteOrder.BIG_ENDIAN)
    bb.put(0x08).put(0x0D.toByte())
    bb.putShort(c.get(Calendar.YEAR).toShort())
    bb.put((c.get(Calendar.MONTH)+1).toByte()).put(c.get(Calendar.DAY_OF_MONTH).toByte())
    bb.put(c.get(Calendar.HOUR_OF_DAY).toByte()).put(c.get(Calendar.MINUTE).toByte()).put(c.get(Calendar.SECOND).toByte())
    return bb.array().map { it.toUByte() }.toUByteArray()
}
private fun UByteArray.toHexString(): String = joinToString(":") { "%02X".format(it.toInt()) }

}

Metadata

Metadata

Assignees

No one assigned

    Labels

    triageNeeds to be reviewed and assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions