[ROKID] [iOS] Rokid Caps: Binary Serialization for BLE Communication in Swift

February 20, 2026 · Pure Swift implementation of the Rokid Caps binary serialization format for BLE communication
[ROKID] [iOS] Rokid Caps: Binary Serialization for BLE Communication in Swift

Overview

The RokidCaps class is a pure Swift implementation of the Rokid Caps binary serialization format used for BLE (Bluetooth Low Energy) communication between the smartphone and Rokid CXR-M glasses. This format is the core messaging protocol for all commands and data exchange.

🔍 Note:

The original Android SDK uses a native libcaps.so library with JNI bindings. For iOS, we reverse-engineered the wire format from captured BLE packets and implemented a pure Swift version without native dependencies.

Wire Format

The Caps binary format uses a compact structure optimized for embedded devices:

┌──────────────────────────────────────────────────────────────┐ │ 4 bytes │ 1 byte │ 1 byte │ N bytes │ variable │ │ BE uint32 │ magic │ count │ types │ data │ │ total_size│ 0x05 │ N │ type codes│ section │ └──────────────────────────────────────────────────────────────┘
FieldSizeEncodingDescription
total_size4 bytesBig-endian uint32Total byte count of the entire serialized output
magic1 byteFixed 0x05Version/magic byte for format validation
count1 byteuint8Number of values in this Caps container
typesN bytesASCIIOne type code byte per member
datavariableMixedPacked value data, one entry per member (same order as types)

Type Codes

Each value in a Caps container is identified by a single-byte ASCII type code:

Type CodeASCIISwift TypeData Encoding
0x56‘V’void0 bytes (no data)
0x69‘i’Int32SLEB128 (signed)
0x75‘u’UInt32ULEB128 (unsigned)
0x6C‘l’Int64SLEB128 (signed)
0x6B‘k’UInt64ULEB128 (unsigned)
0x66‘f’Float4 bytes little-endian IEEE 754
0x64‘d’Double8 bytes little-endian IEEE 754
0x53‘S’StringULEB128 byte-length + UTF-8 bytes (no null terminator)
0x42‘B’DataULEB128 byte-length + raw bytes
0x4F‘O’RokidCapsNested Caps (includes its own 4B BE header)

⚠️ Important:

Booleans are encoded as uint32 values (0 for false, 1 for true). There is no dedicated boolean type code.

LEB128 Encoding

Integer values use LEB128 variable-length encoding to minimize payload size:

  • ULEB128 (Unsigned LEB128): For unsigned integers. Each byte uses 7 bits for data and 1 bit (MSB) as a continuation flag.
  • SLEB128 (Signed LEB128): For signed integers. Uses two’s complement with sign extension.

ULEB128 Encoding Example

Value 300 (binary: 100101100):

Byte 1: 10101100 (0xAC) – bits 0-6 with continuation bit Byte 2: 00000010 (0x02) – bits 7-8, no continuation Result: [0xAC, 0x02]

API Reference

Creating a Caps Container

let caps = RokidCaps()

Writing Values

MethodDescription
writeVoid()Write a void marker
write(int32: Int32)Write a signed 32-bit integer
write(uint32: UInt32)Write an unsigned 32-bit integer
write(int64: Int64)Write a signed 64-bit integer
write(uint64: UInt64)Write an unsigned 64-bit integer
write(float: Float)Write a 32-bit float
write(double: Double)Write a 64-bit double
write(string: String)Write a UTF-8 string
write(bool: Bool)Write a boolean (encoded as uint32)
write(binary: Data)Write raw binary data
write(object: RokidCaps)Write a nested Caps object

Reading Values

Property/MethodDescription
count: IntNumber of values in the container
at(_ index: Int) throws -> ValueGet value at index (throws if out of bounds)

Value Extraction

The Value enum provides type-safe extraction methods:

MethodReturn TypeThrows
getString()StringValueError.wrongType
getInt32()Int32ValueError.wrongType
getUInt32()UInt32ValueError.wrongType
getInt64()Int64ValueError.wrongType
getUInt64()UInt64ValueError.wrongType
getFloat()FloatValueError.wrongType
getDouble()DoubleValueError.wrongType
getBinary()DataValueError.wrongType
getObject()RokidCapsValueError.wrongType

Serialization

MethodDescription
serialize() -> DataConvert to binary wire format
toBase64() -> StringSerialize and encode as Base64
parse(_ data: Data) -> RokidCaps?Parse from binary data (static)
fromBase64(_ base64: String) -> RokidCaps?Parse from Base64 string (static)

Usage Examples

Building a Custom View Command

let caps = RokidCaps()
caps.write(string:Custom_View)
caps.write(string: """ { “type”: “LinearLayout”, “orientation”: “vertical”, “children”: [ {“type”: “TextView”, “text”: “Hello Glasses!”} ] } """)
let data = caps.serialize()
// Send `data` over BLE characteristic

Building a Teleprompter Message

let caps = RokidCaps()
caps.write(string:Teleprompter)
caps.write(string:Line 1 of text”)
caps.write(uint32: 30) // font size
caps.write(uint32: 1) // scroll speed
let base64 = caps.toBase64()
// Send via React Native bridge

Parsing a Response from Glasses

guard let caps = RokidCaps.parse(receivedData) else {
print(Failed to parse Caps data”)
return
}
do {
let commandName = try caps.at(0).getString()
let statusCode = try caps.at(1).getUInt32()
print(Command: \(commandName), Status: \(statusCode))
} catch {
print(Error extracting values: \(error))
}

Nested Caps Objects

let inner = RokidCaps()
inner.write(string: “nested_data”)
inner.write(int32: 42)
let outer = RokidCaps()
outer.write(string: “container”)
outer.write(object: inner)
// Parsing nested objects
if let parsed = RokidCaps.parse(outer.serialize()) {
let nestedCaps = try parsed.at(1).getObject()
let nestedValue = try nestedCaps.at(1).getInt32() // 42
}

BLE Integration

Sending Commands

When sending commands over BLE, serialize the Caps container and write to the appropriate GATT characteristic:

func sendCommand(_ caps: RokidCaps, to characteristic: CBCharacteristic) {
let data = caps.serialize()
peripheral.writeValue(data, for: characteristic, type: .withResponse)
}

Receiving Responses

BLE responses may arrive in multiple chunks. Accumulate data until you have a complete Caps message:

var buffer = Data()
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
guard let chunk = characteristic.value else { return }
buffer.append(chunk)
// Check if we have enough data to read the size header
guard buffer.count >= 4 else { return }
let expectedSize = buffer.withUnsafeBytes { ptr -> UInt32 in
let bytes = ptr.bindMemory(to: UInt8.self)
return UInt32(bytes[0]) << 24 | UInt32(bytes[1]) << 16
| UInt32(bytes[2]) << 8 | UInt32(bytes[3])
}
// Check if we have the complete message
guard buffer.count >= expectedSize else { return }
// Parse the complete message
if let caps = RokidCaps.parse(buffer) {
handleResponse(caps)
}
// Reset buffer for next message
buffer = Data()
}

Wire Format Example

A real-world handshake captured from hardware (145 bytes):

Offset 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F ------ ----------------------------------------------- 0x0000 00 00 00 91 05 03 53 53 75 0C 72 6F 6B 69 64 5F …SSu.rokid_ 0x0010 68 61 6E 64 73 68 61 6B 65 7B 22 64 65 76 69 63 handshake{“devic …

Breakdown:

  • 00 00 00 91 — Total size: 145 bytes (big-endian)
  • 05 — Magic byte
  • 03 — 3 members
  • 53 53 75 — Type codes: string, string, uint32
  • Remaining bytes — Encoded string/integer data

Comparison with Android SDK

AspectAndroid (Java/Kotlin)iOS (Swift)
ImplementationNative libcaps.so via JNIPure Swift
DependencyRequires native libraryNo native dependencies
Thread SafetyUnknown (JNI boundary)Swift value semantics
Error HandlingReturns null/defaultThrows typed errors
Boolean Encodingwrite(boolean) → uint32write(bool:) → uint32

Error Handling

The RokidCaps class uses Swift’s error handling:

public enum ValueError: Error {
case wrongType // Type mismatch when extracting value
case outOfBounds // Index out of bounds
case parseError // Failed to parse binary data
}

Parse failures return nil rather than throwing, allowing for simple optional chaining:

guard let caps = RokidCaps.parse(data) else {
// Handle parse failure
return
}

Performance Considerations

  • Memory: Values are stored as Swift enums, providing efficient memory layout
  • Serialization: Single-pass encoding with pre-allocated capacity
  • Parsing: Zero-copy where possible; strings require UTF-8 validation
  • LEB128: Inline implementation avoids function call overhead

🔍 Note:

For high-frequency messages (e.g., audio streaming), consider reusing RokidCaps instances or using a buffer pool to reduce allocation pressure.

Marcin Miazga