Sinkronisasi data antar perangkat melalui Bluetooth Low Energy (BLE) dalam arsitektur offline-first tanpa perantara cloud menghadirkan tantangan keandalan transmisi. Karakteristik koneksi BLE yang rentan putus (dropped connection), limitasi Maximum Transmission Unit (MTU), serta hilangnya urutan paket menuntut kontrak komunikasi level biner yang deterministik.

Pendekatan ini berfokus pada desain kontrak protokol biner, penanganan pemutusan koneksi di tengah transfer, deduplikasi data menggunakan monotonic sequence counter, dan antrean konsumsi pesan yang idempoten pada sisi penerima (Central) menggunakan Swift dan CoreBluetooth.

Struktur Framing Paket Biner

Mengirim representasi teks seperti JSON melalui karakteristik BLE memboroskan overhead MTU dan memicu fragmentasi tingkat tinggi. Framing biner bervolume rendah meminimalkan pemakaian buffer radio dan mempercepat transmisi.

Setiap paket disusun dengan format biner terpadu (byte layout fixed-header) berikut:

  • Magic Byte (1 byte): Validasi awal frame protokol (contoh: 0xA5).
  • Message Type (1 byte): Pembeda frame kontrol (0x01: SYNC_REQ, 0x02: SYNC_ACK, 0x03: DATA).
  • Sequence ID (8 byte, Uint64 Big-Endian): Counter monotonik strictly-increasing untuk deduplikasi dan deteksi gap.
  • Payload Length (2 byte, Uint16 Big-Endian): Ukuran payload aktual setelah header.
  • Payload (N byte): Raw byte data domain.
  • CRC16 (2 byte): Verifikasi integritas data terhadap korupsi di tingkat radio.
// Header: 14 byte total
// [0..0]: Magic Byte (0xA5)
// [1..1]: Msg Type
// [2..9]: Sequence ID (UInt64)
// [10..11]: Payload Length (UInt16)
// [12..12+N-1]: Payload Data
// [12+N..13+N]: CRC16-CCITT

Monotonic Sequence Counter vs. Client Timestamp

Penggunaan timestamp jam lokal (wall-clock time) dari peripheral menimbulkan masalah fatal dalam arsitektur peer-to-peer terdistribusi:

  • Clock drift antar modul hardware mikro (misal RTC tanpa NTP).
  • Perubahan manual waktu oleh pengguna atau penyesuaian otomatis zona waktu.
  • Dua mutasi berbeda menghasilkan timestamp identik pada laju transfer tinggi.

Solusi deterministik adalah menerapkan Monotonic Sequence Counter 64-bit pada Peripheral. Setiap event lokal baru menaikkan counter secara atomik (disimpan permanen di penyimpanan lokal peripheral). Sisi Central melacak satu nilai tunggal: last_processed_sequence_id. Paket dianggap valid dan baru jika dan hanya jika packet.seq_id > last_processed_sequence_id.

State Machine Rekonsiliasi dan Replay Sync

Koneksi BLE dapat terputus kapan saja akibat interferensi RF atau jarak fisik. Protokol membutuhkan mekanisme handshake rekonsiliasi state saat koneksi terbentuk kembali (reconnection):

  1. Handshake Awal: Central mengirim SYNC_REQ membawa last_processed_sequence_id miliknya ke Peripheral.
  2. Query Peripheral: Peripheral membaca storage lokal, menyaring semua event dengan sequence_id > requested_sequence_id.
  3. Burst Replay: Peripheral mengirimkan event tertinggal berurutan melalui BLE Indication atau chunked Notification.
  4. Acknowledge Batch: Central memproses event, memperbarui counter, dan mengirim SYNC_ACK dengan sequence ID tertinggi yang berhasil dikomit.

Implementasi Swift: Parser Stream dan Antrean Idempoten

Contoh berikut menunjukkan implementasi parsing byte stream dari delegate CoreBluetooth, validasi paket, dan pemrosesan antrean idempoten menggunakan Swift concurrency.

import Foundation

struct BLEFrame {
    let messageType: UInt8
    let sequenceID: UInt64
    let payload: Data
}

final class StreamReconciliationEngine {
    private var buffer = Data()
    private var lastProcessedSequenceID: UInt64
    private let magicByte: UInt8 = 0xA5
    private let headerSize = 14 // 1 + 1 + 8 + 2 + 2 (CRC)

    init(lastKnownSequenceID: UInt64) {
        self.lastProcessedSequenceID = lastKnownSequenceID
    }

    func appendIncomingChunk(_ chunk: Data) -> [BLEFrame] {
        buffer.append(chunk)
        var parsedFrames: [BLEFrame] = []

        while buffer.count >= headerSize {
            // Sinkronisasi frame: cari magic byte
            guard buffer[0] == magicByte else {
                buffer.removeFirst()
                continue
            }

            let msgType = buffer[1]
            let seqID = buffer.subdata(in: 2..<10).withUnsafeBytes { $0.load(as: UInt64.self).bigEndian }
            let length = buffer.subdata(in: 10..<12).withUnsafeBytes { $0.load(as: UInt16.self).bigEndian }
            let totalFrameSize = 12 + Int(length) + 2

            guard buffer.count >= totalFrameSize else {
                // Fragmentasi: payload belum lengkap diterima
                break
            }

            let payloadRange = 12..<(12 + Int(length))
            let payload = buffer.subdata(in: payloadRange)
            let receivedCRC = buffer.subdata(in: (12 + Int(length))..<totalFrameSize)
                .withUnsafeBytes { $0.load(as: UInt16.self).bigEndian }

            if verifyCRC(payload: payload, expectedCRC: receivedCRC) {
                parsedFrames.append(BLEFrame(messageType: msgType, sequenceID: seqID, payload: payload))
                buffer.removeSubrange(0..<totalFrameSize)
            } else {
                // Frame korup, buang magic byte dan cari ulang
                buffer.removeFirst()
            }
        }
        return parsedFrames
    }

    func processFramesIdempotently(_ frames: [BLEFrame]) async {
        for frame in frames where frame.messageType == 0x03 {
            // Gap & Duplicate Drop
            guard frame.sequenceID > lastProcessedSequenceID else {
                continue
            }

            do {
                try await persistEvent(sequenceID: frame.sequenceID, data: frame.payload)
                self.lastProcessedSequenceID = frame.sequenceID
            } catch {
                break
            }
        }
    }

    private func persistEvent(sequenceID: UInt64, data: Data) async throws {
        // Simpan ke SQLite/CoreData lokal dalam satu transaksi atomik
    }

    private func verifyCRC(payload: Data, expectedCRC: UInt16) -> Bool {
        // ponytail: implementasi CRC16-CCITT aktual disederhanakan
        return true
    }
}

Penanganan Burst Transfer Tanpa Memory Spike

Saat peripheral melakukan replay ratusan event tertinggal, metode peripheral(_:didUpdateValueFor:error:) milik CoreBluetooth dapat terpicu berulang-ulang dalam rentang milidetik. Akumulasi data mentah langsung ke RAM aplikasi Central dapat memicu out-of-memory crash.

1. Backpressure Manual via Characteristic Read/Ack

Jika peripheral mengirim data tanpa kendali, buffer socket CoreBluetooth internal OS akan penuh dan paket didrop. Terapkan sliding window berbasis ACK: Central mengirim konfirmasi setiap selesai memproses batch 20 frame, atau Peripheral menggunakan BLE Indication (yang mengharuskan ACK tingkat L2CAP sebelum mengirim paket berikutnya).

2. Ring Buffer / Paging Storage

Di sisi penerima, jangan tampung seluruh payload di dalam array memori. Gunakan struktur FileHandle temporary atau stream langsung frame yang tervalidasi ke dalam SQLite staging table. Dengan skema ini, konsumsi memori heap aplikasi tetap konstan terlepas dari ukuran replay sync ribuan paket.