fix: align NIP-BE chunk format with KoalaSat/samiz reference implementation
Reviewed the samiz BLE implementation and fixed compatibility issues: - Chunk index: changed from 2 bytes to 1 byte (matching samiz/NIP-BE example) - Chunk overhead: 2 bytes total (1 index + 1 total count), not 3 - chunkSize parameter now means payload size (500), not total chunk size - Android: TX power HIGH, advertise timeout indefinite (matching samiz) - Android: request MTU 512 and CONNECTION_PRIORITY_HIGH on connect - Android: discover services after MTU negotiation (samiz flow) - Android: set WRITE_TYPE_DEFAULT on write characteristic - MTU-to-chunkSize: properly accounts for ATT overhead (3) + chunk overhead (2) These changes ensure wire-level compatibility with samiz devices. https://claude.ai/code/session_01Tz5E73Rj7tL48A3qUGS5DT
This commit is contained in:
+7
-2
@@ -103,7 +103,8 @@ class AndroidBleTransport(
|
|||||||
.Builder()
|
.Builder()
|
||||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
|
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
|
||||||
.setConnectable(true)
|
.setConnectable(true)
|
||||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
|
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
|
||||||
|
.setTimeout(0)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val data =
|
val data =
|
||||||
@@ -158,6 +159,7 @@ class AndroidBleTransport(
|
|||||||
BluetoothGattCharacteristic.PROPERTY_WRITE,
|
BluetoothGattCharacteristic.PROPERTY_WRITE,
|
||||||
BluetoothGattCharacteristic.PERMISSION_WRITE,
|
BluetoothGattCharacteristic.PERMISSION_WRITE,
|
||||||
)
|
)
|
||||||
|
writeChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||||
|
|
||||||
readCharacteristic =
|
readCharacteristic =
|
||||||
BluetoothGattCharacteristic(
|
BluetoothGattCharacteristic(
|
||||||
@@ -364,7 +366,8 @@ class AndroidBleTransport(
|
|||||||
val peer = peerMap[peerUuid] ?: return
|
val peer = peerMap[peerUuid] ?: return
|
||||||
|
|
||||||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||||
gatt.discoverServices()
|
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
|
||||||
|
gatt.requestMtu(BleConfig.DEFAULT_MTU)
|
||||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||||
listener?.onPeerDisconnected(peer)
|
listener?.onPeerDisconnected(peer)
|
||||||
}
|
}
|
||||||
@@ -428,6 +431,8 @@ class AndroidBleTransport(
|
|||||||
val peer = peerMap[peerUuid] ?: return
|
val peer = peerMap[peerUuid] ?: return
|
||||||
listener?.onMtuChanged(peer, mtu)
|
listener?.onMtuChanged(peer, mtu)
|
||||||
}
|
}
|
||||||
|
// Discover services after MTU negotiation (matching samiz flow)
|
||||||
|
gatt.discoverServices()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,9 +42,15 @@ object BleConfig {
|
|||||||
/** Maximum compressed message size (64KB). */
|
/** Maximum compressed message size (64KB). */
|
||||||
const val MAX_MESSAGE_SIZE = 64 * 1024
|
const val MAX_MESSAGE_SIZE = 64 * 1024
|
||||||
|
|
||||||
/** Default chunk size for message batching. */
|
/**
|
||||||
|
* Default payload size per chunk in bytes (excluding 2-byte overhead).
|
||||||
|
* Each transmitted chunk is `DEFAULT_CHUNK_SIZE + 2` bytes on the wire.
|
||||||
|
*/
|
||||||
const val DEFAULT_CHUNK_SIZE = 500
|
const val DEFAULT_CHUNK_SIZE = 500
|
||||||
|
|
||||||
|
/** Default MTU to request during BLE connection. */
|
||||||
|
const val DEFAULT_MTU = 512
|
||||||
|
|
||||||
/** Minimum MTU for BLE 4.2. */
|
/** Minimum MTU for BLE 4.2. */
|
||||||
const val MIN_MTU_BLE_4_2 = 20
|
const val MIN_MTU_BLE_4_2 = 20
|
||||||
|
|
||||||
|
|||||||
@@ -223,7 +223,8 @@ class BleMeshManager(
|
|||||||
peer: BlePeer,
|
peer: BlePeer,
|
||||||
mtu: Int,
|
mtu: Int,
|
||||||
) {
|
) {
|
||||||
val chunkSize = mtu - 3
|
// ATT overhead is 3 bytes, chunk framing is 2 bytes (index + total count)
|
||||||
|
val chunkSize = mtu - 3 - BleMessageChunker.CHUNK_OVERHEAD
|
||||||
clients[peer.deviceUuid]?.chunkSize = chunkSize
|
clients[peer.deviceUuid]?.chunkSize = chunkSize
|
||||||
servers[peer.deviceUuid]?.chunkSize = chunkSize
|
servers[peer.deviceUuid]?.chunkSize = chunkSize
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-19
@@ -27,18 +27,22 @@ import com.vitorpamplona.quartz.utils.Deflate
|
|||||||
*
|
*
|
||||||
* NIP-BE messages are DEFLATE-compressed and split into chunks with the format:
|
* NIP-BE messages are DEFLATE-compressed and split into chunks with the format:
|
||||||
* ```
|
* ```
|
||||||
* [batch index (first 2 bytes)][payload][total batches (last byte)]
|
* [chunk index (1 byte)][payload (up to chunkSize bytes)][total chunks (1 byte)]
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* Each chunk carries a 2-byte index prefix and a 1-byte total count suffix,
|
* Each chunk carries a 1-byte index prefix and a 1-byte total count suffix
|
||||||
* so the payload per chunk is `chunkSize - 3` bytes of compressed data.
|
* (2 bytes overhead), matching the reference implementation in KoalaSat/samiz.
|
||||||
*/
|
*/
|
||||||
object BleMessageChunker {
|
object BleMessageChunker {
|
||||||
|
/** Per-chunk overhead: 1 byte index + 1 byte total count. */
|
||||||
|
const val CHUNK_OVERHEAD = 2
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compresses and splits a NIP-01 JSON message into BLE-sized chunks.
|
* Compresses and splits a NIP-01 JSON message into BLE-sized chunks.
|
||||||
*
|
*
|
||||||
* @param message The JSON message string (e.g., `["EVENT", {...}]`).
|
* @param message The JSON message string (e.g., `["EVENT", {...}]`).
|
||||||
* @param chunkSize The maximum size of each chunk in bytes (default 500).
|
* @param chunkSize The maximum payload size per chunk in bytes (default 500).
|
||||||
|
* The actual transmitted chunk size is `payload + 2` (index + total count).
|
||||||
* @return Array of byte arrays, each representing one chunk.
|
* @return Array of byte arrays, each representing one chunk.
|
||||||
* @throws IllegalArgumentException if compressed message exceeds 64KB.
|
* @throws IllegalArgumentException if compressed message exceeds 64KB.
|
||||||
*/
|
*/
|
||||||
@@ -52,26 +56,23 @@ object BleMessageChunker {
|
|||||||
"Compressed message size ${compressed.size} exceeds maximum ${BleConfig.MAX_MESSAGE_SIZE} bytes"
|
"Compressed message size ${compressed.size} exceeds maximum ${BleConfig.MAX_MESSAGE_SIZE} bytes"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overhead per chunk: 2 bytes index prefix + 1 byte total count suffix
|
require(chunkSize > 0) { "chunkSize must be positive" }
|
||||||
val payloadSize = chunkSize - 3
|
|
||||||
require(payloadSize > 0) { "chunkSize must be at least 4 bytes" }
|
|
||||||
|
|
||||||
val numChunks = (compressed.size + payloadSize - 1) / payloadSize
|
val numChunks = (compressed.size + chunkSize - 1) / chunkSize
|
||||||
|
|
||||||
require(numChunks <= 255) {
|
require(numChunks <= 255) {
|
||||||
"Message requires $numChunks chunks but maximum is 255"
|
"Message requires $numChunks chunks but maximum is 255"
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array(numChunks) { i ->
|
return Array(numChunks) { i ->
|
||||||
val start = i * payloadSize
|
val start = i * chunkSize
|
||||||
val end = minOf((i + 1) * payloadSize, compressed.size)
|
val end = minOf((i + 1) * chunkSize, compressed.size)
|
||||||
val payload = compressed.copyOfRange(start, end)
|
val payload = compressed.copyOfRange(start, end)
|
||||||
|
|
||||||
// [2-byte index][payload][1-byte total]
|
// [1-byte index][payload][1-byte total]
|
||||||
val chunk = ByteArray(payload.size + 3)
|
val chunk = ByteArray(payload.size + CHUNK_OVERHEAD)
|
||||||
chunk[0] = (i shr 8).toByte()
|
chunk[0] = i.toByte()
|
||||||
chunk[1] = (i and 0xFF).toByte()
|
payload.copyInto(chunk, 1)
|
||||||
payload.copyInto(chunk, 2)
|
|
||||||
chunk[chunk.size - 1] = numChunks.toByte()
|
chunk[chunk.size - 1] = numChunks.toByte()
|
||||||
|
|
||||||
chunk
|
chunk
|
||||||
@@ -81,7 +82,7 @@ object BleMessageChunker {
|
|||||||
/**
|
/**
|
||||||
* Reassembles and decompresses chunks back into a NIP-01 JSON message.
|
* Reassembles and decompresses chunks back into a NIP-01 JSON message.
|
||||||
*
|
*
|
||||||
* Chunks can arrive in any order; they are sorted by their index prefix.
|
* Chunks can arrive in any order; they are sorted by their index byte.
|
||||||
*
|
*
|
||||||
* @param chunks The received chunks.
|
* @param chunks The received chunks.
|
||||||
* @return The original JSON message string.
|
* @return The original JSON message string.
|
||||||
@@ -91,7 +92,7 @@ object BleMessageChunker {
|
|||||||
|
|
||||||
var reassembled = ByteArray(0)
|
var reassembled = ByteArray(0)
|
||||||
for (chunk in sorted) {
|
for (chunk in sorted) {
|
||||||
val payload = chunk.copyOfRange(2, chunk.size - 1)
|
val payload = chunk.copyOfRange(1, chunk.size - 1)
|
||||||
val newArray = ByteArray(reassembled.size + payload.size)
|
val newArray = ByteArray(reassembled.size + payload.size)
|
||||||
reassembled.copyInto(newArray)
|
reassembled.copyInto(newArray)
|
||||||
payload.copyInto(newArray, reassembled.size)
|
payload.copyInto(newArray, reassembled.size)
|
||||||
@@ -102,9 +103,9 @@ object BleMessageChunker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the 2-byte chunk index from a chunk.
|
* Extracts the 1-byte chunk index from a chunk.
|
||||||
*/
|
*/
|
||||||
fun chunkIndex(chunk: ByteArray): Int = ((chunk[0].toInt() and 0xFF) shl 8) or (chunk[1].toInt() and 0xFF)
|
fun chunkIndex(chunk: ByteArray): Int = chunk[0].toInt() and 0xFF
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the total number of expected chunks from a chunk's last byte.
|
* Extracts the total number of expected chunks from a chunk's last byte.
|
||||||
|
|||||||
+8
-4
@@ -48,15 +48,19 @@ class BleMessageChunkerTest {
|
|||||||
@Test
|
@Test
|
||||||
fun chunksHaveCorrectFormat() {
|
fun chunksHaveCorrectFormat() {
|
||||||
val message = """["EVENT",{"id":"abc","kind":1,"content":"hello world"}]"""
|
val message = """["EVENT",{"id":"abc","kind":1,"content":"hello world"}]"""
|
||||||
val chunks = BleMessageChunker.splitIntoChunks(message, chunkSize = 20)
|
val chunkSize = 20
|
||||||
|
val chunks = BleMessageChunker.splitIntoChunks(message, chunkSize = chunkSize)
|
||||||
|
|
||||||
for ((i, chunk) in chunks.withIndex()) {
|
for ((i, chunk) in chunks.withIndex()) {
|
||||||
// First 2 bytes are the index
|
// First byte is the index
|
||||||
assertEquals(i, BleMessageChunker.chunkIndex(chunk))
|
assertEquals(i, BleMessageChunker.chunkIndex(chunk))
|
||||||
// Last byte is the total count
|
// Last byte is the total count
|
||||||
assertEquals(chunks.size, BleMessageChunker.totalChunks(chunk))
|
assertEquals(chunks.size, BleMessageChunker.totalChunks(chunk))
|
||||||
// Each chunk should be at most chunkSize
|
// Each chunk should be at most chunkSize + 2 (overhead)
|
||||||
assertTrue(chunk.size <= 20, "Chunk size ${chunk.size} exceeds max 20")
|
assertTrue(
|
||||||
|
chunk.size <= chunkSize + BleMessageChunker.CHUNK_OVERHEAD,
|
||||||
|
"Chunk size ${chunk.size} exceeds max ${chunkSize + BleMessageChunker.CHUNK_OVERHEAD}",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user