[ROKID] [iOS] Rokid Caps: Binary Serialization for BLE Communication in Swift
![[ROKID] [iOS] Rokid Caps: Binary Serialization for BLE Communication in Swift](/rhinoceros.webp)
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 │
└──────────────────────────────────────────────────────────────┘
| Field | Size | Encoding | Description |
|---|---|---|---|
total_size | 4 bytes | Big-endian uint32 | Total byte count of the entire serialized output |
magic | 1 byte | Fixed 0x05 | Version/magic byte for format validation |
count | 1 byte | uint8 | Number of values in this Caps container |
types | N bytes | ASCII | One type code byte per member |
data | variable | Mixed | Packed 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 Code | ASCII | Swift Type | Data Encoding |
|---|---|---|---|
0x56 | ‘V’ | void | 0 bytes (no data) |
0x69 | ‘i’ | Int32 | SLEB128 (signed) |
0x75 | ‘u’ | UInt32 | ULEB128 (unsigned) |
0x6C | ‘l’ | Int64 | SLEB128 (signed) |
0x6B | ‘k’ | UInt64 | ULEB128 (unsigned) |
0x66 | ‘f’ | Float | 4 bytes little-endian IEEE 754 |
0x64 | ‘d’ | Double | 8 bytes little-endian IEEE 754 |
0x53 | ‘S’ | String | ULEB128 byte-length + UTF-8 bytes (no null terminator) |
0x42 | ‘B’ | Data | ULEB128 byte-length + raw bytes |
0x4F | ‘O’ | RokidCaps | Nested 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
| Method | Description |
|---|---|
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/Method | Description |
|---|---|
count: Int | Number of values in the container |
at(_ index: Int) throws -> Value | Get value at index (throws if out of bounds) |
Value Extraction
The Value enum provides type-safe extraction methods:
| Method | Return Type | Throws |
|---|---|---|
getString() | String | ValueError.wrongType |
getInt32() | Int32 | ValueError.wrongType |
getUInt32() | UInt32 | ValueError.wrongType |
getInt64() | Int64 | ValueError.wrongType |
getUInt64() | UInt64 | ValueError.wrongType |
getFloat() | Float | ValueError.wrongType |
getDouble() | Double | ValueError.wrongType |
getBinary() | Data | ValueError.wrongType |
getObject() | RokidCaps | ValueError.wrongType |
Serialization
| Method | Description |
|---|---|
serialize() -> Data | Convert to binary wire format |
toBase64() -> String | Serialize 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 byte03— 3 members53 53 75— Type codes: string, string, uint32- Remaining bytes — Encoded string/integer data
Comparison with Android SDK
| Aspect | Android (Java/Kotlin) | iOS (Swift) |
|---|---|---|
| Implementation | Native libcaps.so via JNI | Pure Swift |
| Dependency | Requires native library | No native dependencies |
| Thread Safety | Unknown (JNI boundary) | Swift value semantics |
| Error Handling | Returns null/default | Throws typed errors |
| Boolean Encoding | write(boolean) → uint32 | write(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.