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()
|
||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
|
||||
.setConnectable(true)
|
||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
|
||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
|
||||
.setTimeout(0)
|
||||
.build()
|
||||
|
||||
val data =
|
||||
@@ -158,6 +159,7 @@ class AndroidBleTransport(
|
||||
BluetoothGattCharacteristic.PROPERTY_WRITE,
|
||||
BluetoothGattCharacteristic.PERMISSION_WRITE,
|
||||
)
|
||||
writeChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
|
||||
readCharacteristic =
|
||||
BluetoothGattCharacteristic(
|
||||
@@ -364,7 +366,8 @@ class AndroidBleTransport(
|
||||
val peer = peerMap[peerUuid] ?: return
|
||||
|
||||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
gatt.discoverServices()
|
||||
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
|
||||
gatt.requestMtu(BleConfig.DEFAULT_MTU)
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
listener?.onPeerDisconnected(peer)
|
||||
}
|
||||
@@ -428,6 +431,8 @@ class AndroidBleTransport(
|
||||
val peer = peerMap[peerUuid] ?: return
|
||||
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). */
|
||||
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
|
||||
|
||||
/** Default MTU to request during BLE connection. */
|
||||
const val DEFAULT_MTU = 512
|
||||
|
||||
/** Minimum MTU for BLE 4.2. */
|
||||
const val MIN_MTU_BLE_4_2 = 20
|
||||
|
||||
|
||||
@@ -223,7 +223,8 @@ class BleMeshManager(
|
||||
peer: BlePeer,
|
||||
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
|
||||
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:
|
||||
* ```
|
||||
* [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,
|
||||
* so the payload per chunk is `chunkSize - 3` bytes of compressed data.
|
||||
* Each chunk carries a 1-byte index prefix and a 1-byte total count suffix
|
||||
* (2 bytes overhead), matching the reference implementation in KoalaSat/samiz.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @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.
|
||||
* @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"
|
||||
}
|
||||
|
||||
// Overhead per chunk: 2 bytes index prefix + 1 byte total count suffix
|
||||
val payloadSize = chunkSize - 3
|
||||
require(payloadSize > 0) { "chunkSize must be at least 4 bytes" }
|
||||
require(chunkSize > 0) { "chunkSize must be positive" }
|
||||
|
||||
val numChunks = (compressed.size + payloadSize - 1) / payloadSize
|
||||
val numChunks = (compressed.size + chunkSize - 1) / chunkSize
|
||||
|
||||
require(numChunks <= 255) {
|
||||
"Message requires $numChunks chunks but maximum is 255"
|
||||
}
|
||||
|
||||
return Array(numChunks) { i ->
|
||||
val start = i * payloadSize
|
||||
val end = minOf((i + 1) * payloadSize, compressed.size)
|
||||
val start = i * chunkSize
|
||||
val end = minOf((i + 1) * chunkSize, compressed.size)
|
||||
val payload = compressed.copyOfRange(start, end)
|
||||
|
||||
// [2-byte index][payload][1-byte total]
|
||||
val chunk = ByteArray(payload.size + 3)
|
||||
chunk[0] = (i shr 8).toByte()
|
||||
chunk[1] = (i and 0xFF).toByte()
|
||||
payload.copyInto(chunk, 2)
|
||||
// [1-byte index][payload][1-byte total]
|
||||
val chunk = ByteArray(payload.size + CHUNK_OVERHEAD)
|
||||
chunk[0] = i.toByte()
|
||||
payload.copyInto(chunk, 1)
|
||||
chunk[chunk.size - 1] = numChunks.toByte()
|
||||
|
||||
chunk
|
||||
@@ -81,7 +82,7 @@ object BleMessageChunker {
|
||||
/**
|
||||
* 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.
|
||||
* @return The original JSON message string.
|
||||
@@ -91,7 +92,7 @@ object BleMessageChunker {
|
||||
|
||||
var reassembled = ByteArray(0)
|
||||
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)
|
||||
reassembled.copyInto(newArray)
|
||||
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.
|
||||
|
||||
+8
-4
@@ -48,15 +48,19 @@ class BleMessageChunkerTest {
|
||||
@Test
|
||||
fun chunksHaveCorrectFormat() {
|
||||
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()) {
|
||||
// First 2 bytes are the index
|
||||
// First byte is the index
|
||||
assertEquals(i, BleMessageChunker.chunkIndex(chunk))
|
||||
// Last byte is the total count
|
||||
assertEquals(chunks.size, BleMessageChunker.totalChunks(chunk))
|
||||
// Each chunk should be at most chunkSize
|
||||
assertTrue(chunk.size <= 20, "Chunk size ${chunk.size} exceeds max 20")
|
||||
// Each chunk should be at most chunkSize + 2 (overhead)
|
||||
assertTrue(
|
||||
chunk.size <= chunkSize + BleMessageChunker.CHUNK_OVERHEAD,
|
||||
"Chunk size ${chunk.size} exceeds max ${chunkSize + BleMessageChunker.CHUNK_OVERHEAD}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user