Summary
On the JVM (btleplug) target, Peripheral.maximumWriteValueLengthForType always returns 20, regardless of the
MTU actually negotiated with the peripheral. It also ignores its writeType argument.
This contradicts the documented contract of the function, and unlike the JavaScript target — where the same
limitation exists and is documented — there is nothing in the KDoc telling a caller that JVM behaves this way.
Environment
- Kable
0.42.0, and unchanged on main as of 0.44.3 (2026-07-22)
- Target: JVM (btleplug), observed on Windows
Detail
kable-core/src/jvmMain/kotlin/com/juul/kable/btleplug/BtleplugPeripheral.kt:
private const val DEFAULT_ATT_MTU = 23
private const val ATT_MTU_HEADER_SIZE = 3
// ...
override suspend fun maximumWriteValueLengthForType(writeType: WriteType): Int =
DEFAULT_ATT_MTU - ATT_MTU_HEADER_SIZE
The declared contract in kable-core/src/commonMain/kotlin/Peripheral.kt is:
Return the current ATT MTU size, minus the size of the ATT headers (3 bytes).
On Android, this will be the default (23 - 3) unless you called requestMtu when connecting.
For iOS, this is automatically negotiated, and can also vary depending on the writeType.
On JavaScript, this will return the default (23 - 3) every time as there is no ATT MTU property available.
JVM is absent from that list. Since three of the four targets are described and the JS caveat is called out
explicitly, the omission reads as "JVM behaves like Android/iOS" — which is what makes this quiet rather than
obvious.
Expected: either the current negotiated MTU minus 3, or a documented statement that JVM returns the default,
as JavaScript already has.
Actual: 20, always.
Why this is fixable rather than an inherent btleplug limitation
btleplug's Peripheral trait carries the value already — fn mtu(&self) -> u16, "Returns the currently
negotiated mtu size" — and it is a required trait method as of btleplug 0.12.0, which Kable already tracks
(#1120). So this looks like surfacing one existing FFI call rather than adding capability:
override suspend fun maximumWriteValueLengthForType(writeType: WriteType): Int =
ffi.mtu().toInt() - ATT_MTU_HEADER_SIZE
(kable-btleplug-ffi 0.42.0 does not expose mtu at all, so the FFI side needs the binding too.)
Repro
val peripheral = Peripheral(identifier)
peripheral.connect()
// Prints 20 on JVM regardless of what the OS and peripheral negotiated.
println(peripheral.maximumWriteValueLengthForType(WriteType.WithResponse))
Why it matters to us
We use maximumWriteValueLengthForType to decide whether to fragment outbound writes. The peripheral we talk to
prefers an MTU of 247, and the very first client write of every session is a ~132-byte authentication packet.
A hardcoded 20 is worse than no answer here: it is indistinguishable from a genuinely negotiated 20, so a caller
that trusts it fragments messages that never needed fragmenting. In our case whole writes of ~141 bytes were
observed succeeding on this path against real hardware, while splitting to the reported 20 did not work — the
reported value was not merely conservative, it was actively misleading. We currently special-case the JVM target
to treat the number as unknown and skip fragmentation entirely, which is the only safe reading of it.
This also interacts with #298 (throw when a write exceeds the MTU): implemented against the current JVM value,
that would reject every write over 20 bytes on a link that can carry far more.
Secondary, related: no way to request an MTU on JVM
There is no requestMtu on the JVM path — ServicesDiscoveredPeripheral.requestMtu is Android-only — so a JVM
caller can neither ask for a larger MTU nor find out what it got. Whether that belongs in this issue or in the
builder-configuration work already under way in #867 / #811 is yours to decide; happy to split it out.
Related
Summary
On the JVM (btleplug) target,
Peripheral.maximumWriteValueLengthForTypealways returns20, regardless of theMTU actually negotiated with the peripheral. It also ignores its
writeTypeargument.This contradicts the documented contract of the function, and unlike the JavaScript target — where the same
limitation exists and is documented — there is nothing in the KDoc telling a caller that JVM behaves this way.
Environment
0.42.0, and unchanged onmainas of0.44.3(2026-07-22)Detail
kable-core/src/jvmMain/kotlin/com/juul/kable/btleplug/BtleplugPeripheral.kt:The declared contract in
kable-core/src/commonMain/kotlin/Peripheral.ktis:JVM is absent from that list. Since three of the four targets are described and the JS caveat is called out
explicitly, the omission reads as "JVM behaves like Android/iOS" — which is what makes this quiet rather than
obvious.
Expected: either the current negotiated MTU minus 3, or a documented statement that JVM returns the default,
as JavaScript already has.
Actual:
20, always.Why this is fixable rather than an inherent btleplug limitation
btleplug's
Peripheraltrait carries the value already —fn mtu(&self) -> u16, "Returns the currentlynegotiated mtu size" — and it is a required trait method as of btleplug 0.12.0, which Kable already tracks
(#1120). So this looks like surfacing one existing FFI call rather than adding capability:
(
kable-btleplug-ffi0.42.0 does not exposemtuat all, so the FFI side needs the binding too.)Repro
Why it matters to us
We use
maximumWriteValueLengthForTypeto decide whether to fragment outbound writes. The peripheral we talk toprefers an MTU of 247, and the very first client write of every session is a ~132-byte authentication packet.
A hardcoded
20is worse than no answer here: it is indistinguishable from a genuinely negotiated 20, so a callerthat trusts it fragments messages that never needed fragmenting. In our case whole writes of ~141 bytes were
observed succeeding on this path against real hardware, while splitting to the reported 20 did not work — the
reported value was not merely conservative, it was actively misleading. We currently special-case the JVM target
to treat the number as unknown and skip fragmentation entirely, which is the only safe reading of it.
This also interacts with #298 (throw when a write exceeds the MTU): implemented against the current JVM value,
that would reject every write over 20 bytes on a link that can carry far more.
Secondary, related: no way to request an MTU on JVM
There is no
requestMtuon the JVM path —ServicesDiscoveredPeripheral.requestMtuis Android-only — so a JVMcaller can neither ask for a larger MTU nor find out what it got. Whether that belongs in this issue or in the
builder-configuration work already under way in #867 / #811 is yours to decide; happy to split it out.
Related
maximumWriteValueLengthtoPeripheral(iOS/Android/Web); JVM appears to have received theconstant stub at that point
Android.requestMtu#811 — moving MTU configuration onto the peripheral builder, the natural home for a JVM request pathwriteis performed with data size exceeding MTU #298 — validating writes against the MTU, which depends on this value being realmtu()available