diff --git a/app/src/main/java/net/duhowpi/ftmsbridge/ble/BleConnectionManager.kt b/app/src/main/java/net/duhowpi/ftmsbridge/ble/BleConnectionManager.kt index b2febc3..d82af81 100644 --- a/app/src/main/java/net/duhowpi/ftmsbridge/ble/BleConnectionManager.kt +++ b/app/src/main/java/net/duhowpi/ftmsbridge/ble/BleConnectionManager.kt @@ -41,6 +41,10 @@ class BleConnectionManager( private var controlPointChar: BluetoothGattCharacteristic? = null + /** ATT MTU in force on the current link; the notification payload is this minus 3. */ + var negotiatedMtu = DEFAULT_ATT_MTU + private set + var isConnected = false private set @@ -231,10 +235,17 @@ class BleConnectionManager( when (newState) { BluetoothProfile.STATE_CONNECTED -> { Log.i(tag, "Connected to ${gatt.device.name}") - debugLogger.logMessage("GATT connected, discovering services...") isConnected = true listener?.onConnected(gatt.device.name ?: "Unknown") - gatt.discoverServices() + // The default ATT MTU of 23 leaves only 20 bytes for a notification, + // which is not enough for a Treadmill Data packet that includes heart + // rate. Ask for more before discovering services; discovery starts from + // onMtuChanged either way, so a refusal is not fatal. + debugLogger.logMessage("GATT connected, requesting MTU $REQUESTED_MTU...") + if (!gatt.requestMtu(REQUESTED_MTU)) { + debugLogger.logMessage("MTU request rejected outright, discovering services...") + gatt.discoverServices() + } } BluetoothProfile.STATE_DISCONNECTED -> { Log.i(tag, "Disconnected") @@ -255,6 +266,17 @@ class BleConnectionManager( } } + override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) return + // mtu includes the 3-byte ATT header, so the usable notification payload is mtu - 3. + negotiatedMtu = if (status == BluetoothGatt.GATT_SUCCESS) mtu else DEFAULT_ATT_MTU + debugLogger.logMessage( + "MTU negotiated: $negotiatedMtu (status=$status, payload=${negotiatedMtu - ATT_HEADER_SIZE} bytes)" + ) + Log.i(tag, "MTU negotiated: $negotiatedMtu (status=$status)") + gatt.discoverServices() + } + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status != BluetoothGatt.GATT_SUCCESS) { debugLogger.logMessage("Service discovery failed: $status") @@ -486,5 +508,18 @@ class BleConnectionManager( companion object { private const val RESISTANCE_LOG_MIN_LEVEL = 1 private const val RESISTANCE_LOG_MAX_LEVEL = 22 + + /** ATT MTU every LE link starts at, leaving a 20-byte notification payload. */ + private const val DEFAULT_ATT_MTU = 23 + + /** Bytes of ATT overhead in every notification. */ + private const val ATT_HEADER_SIZE = 3 + + /** + * MTU requested on connect. A Treadmill Data packet carrying heart rate needs 21 + * bytes, one more than the default link allows, so the last field is dropped. + * 517 is the maximum an LE link can negotiate; devices are free to answer lower. + */ + private const val REQUESTED_MTU = 517 } } diff --git a/app/src/main/java/net/duhowpi/ftmsbridge/ftms/FtmsDataParser.kt b/app/src/main/java/net/duhowpi/ftmsbridge/ftms/FtmsDataParser.kt index f32dae2..e580259 100644 --- a/app/src/main/java/net/duhowpi/ftmsbridge/ftms/FtmsDataParser.kt +++ b/app/src/main/java/net/duhowpi/ftmsbridge/ftms/FtmsDataParser.kt @@ -9,8 +9,43 @@ object FtmsDataParser { private val tag = "FtmsDataParser" + /** + * Bytes of zero padding added behind every packet before parsing. + * + * A machine can describe more fields in its flags than the link can carry: with the + * default ATT MTU of 23 a notification holds 20 bytes, while a Treadmill Data packet + * that includes heart rate needs 21. Parsing such a packet straight out of a + * [ByteBuffer] throws [java.nio.BufferUnderflowException] part way through, and + * because notifications are delivered on a binder callback thread the exception is + * swallowed and the entire sample is lost — including the speed and distance that + * were present at the front of the packet. + * + * Truncation only ever removes trailing fields, so padding lets the fields that did + * arrive parse normally while the missing ones read back as zero. 16 bytes covers the + * longest tail any of these characteristics can declare. + */ + private const val TRUNCATION_PADDING = 16 + + /** Wraps [data] for parsing so that a short packet yields partial data, not none. */ + private fun paddedBuffer(data: ByteArray): ByteBuffer = + ByteBuffer.wrap(data.copyOf(data.size + TRUNCATION_PADDING)).order(ByteOrder.LITTLE_ENDIAN) + + /** + * Logs when a packet described more data than it carried, so a machine whose + * notifications do not fit the current MTU is visible rather than silently degraded. + */ + private fun warnIfTruncated(buf: ByteBuffer, data: ByteArray, flags: Int, what: String) { + if (buf.position() > data.size) { + Log.w( + tag, + "$what packet truncated: flags=0x%04X declared %d bytes, link delivered %d" + .format(flags, buf.position(), data.size) + ) + } + } + fun parseTreadmillData(data: ByteArray): FitnessSample { - val buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + val buf = paddedBuffer(data) val flags = buf.short.toInt() and 0xFFFF // Bit 0: More Data (1 = no instantaneous speed in this packet) @@ -100,6 +135,7 @@ object FtmsDataParser { buf.short // skip percentage (sint16) } + warnIfTruncated(buf, data, flags, "Treadmill") Log.d(tag, "treadmill raw: speed=%.2f km/h incl=%.1f%% dist=${totalDistanceM}m kcal=${totalEnergyKcal} hr=${heartRateBpm} t=${elapsedTimeSec}s" .format(speedKmh, inclinationPercent)) return FitnessSample( @@ -122,7 +158,7 @@ object FtmsDataParser { } fun parseIndoorBikeData(data: ByteArray): FitnessSample { - val buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + val buf = paddedBuffer(data) val flags = buf.short.toInt() and 0xFFFF // Bit 0: More Data (0 = Instantaneous Speed present) @@ -198,6 +234,7 @@ object FtmsDataParser { remainingTimeSec = buf.short.toInt() and 0xFFFF } + warnIfTruncated(buf, data, flags, "Indoor bike") return FitnessSample( speedKmh = speedKmh, averageSpeedKmh = averageSpeedKmh, @@ -238,7 +275,7 @@ object FtmsDataParser { * bit 14: Remaining Time present (uint16 s) */ fun parseCrossTrainerData(data: ByteArray): FitnessSample { - val buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + val buf = paddedBuffer(data) val flags = buf.short.toInt() and 0xFFFF var speedKmh = 0.0 @@ -327,6 +364,7 @@ object FtmsDataParser { remainingTimeSec = buf.short.toInt() and 0xFFFF } + warnIfTruncated(buf, data, flags, "Cross trainer") return FitnessSample( speedKmh = speedKmh, averageSpeedKmh = averageSpeedKmh, @@ -366,7 +404,7 @@ object FtmsDataParser { * bit 9: Remaining Time present (uint16 s) */ fun parseStairClimberData(data: ByteArray): FitnessSample { - val buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + val buf = paddedBuffer(data) val flags = buf.short.toInt() and 0xFFFF var cadenceRpm = 0.0 @@ -424,6 +462,7 @@ object FtmsDataParser { remainingTimeSec = buf.short.toInt() and 0xFFFF } + warnIfTruncated(buf, data, flags, "Stair climber") return FitnessSample( cadenceRpm = cadenceRpm, averageCadenceRpm = averageCadenceRpm,