From b9a0cc5e657644f859d0da0bf05b0624e1fde2c3 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 20 Aug 2026 10:11:19 -0400 Subject: [PATCH] fix: negotiate MTU and survive truncated FTMS packets A Woodway treadmill reporting heart rate sends a 21-byte Treadmill Data packet. The link was left at the default ATT MTU of 23, which carries only 20 bytes, so every one of those notifications arrived one byte short. Parsing ran straight off a ByteBuffer wrapping the packet, so the read of the last field threw BufferUnderflowException. Notifications are delivered on a binder callback thread, so the exception was swallowed and the whole sample was dropped, including the speed and distance that had already been parsed out of the front of the packet. Over two sessions on the machine, 591 of 602 notifications were discarded; the 11 that survived were the ones sent while no heart rate was present. The workout recorded zero distance and zero speed while the treadmill was reporting both correctly. Request an MTU on connect so packets arrive whole, and pad packets before parsing so a machine whose notifications still do not fit degrades to partial data instead of none. Log when a packet declares more than the link delivered, so this cannot fail silently again. --- .../ftmsbridge/ble/BleConnectionManager.kt | 39 ++++++++++++++- .../duhowpi/ftmsbridge/ftms/FtmsDataParser.kt | 47 +++++++++++++++++-- 2 files changed, 80 insertions(+), 6 deletions(-) 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,