Sprite Mobile — BLE Integration
Analysis of the BLE layer in Sprite Mobile: device scanning, RSSI monitoring, distance calculation, and comparison with CAMDOM's custom mesh protocol
Sprite Mobile — BLE Integration
Overview
Sprite Mobile uses Bluetooth Low Energy (BLE) for a single purpose: monitoring the RSSI signal strength of a paired Knocklace jewelry device to estimate physical distance. Unlike camdom, which implements a custom BLE mesh networking protocol for multi-device coordination, Sprite uses a standard BLE peripheral scanning model — one phone monitors one device at a time.
BLE Library: Forked react-native-ble-plx
The project vendors react-native-ble-plx as a local module at modules/react-native-ble-plx/. This is a complete fork of the library including all native source code (Java for Android, Swift/ObjC for iOS).
Why Fork?
The fork allows direct modification of:
- Scan parameters —
allowDuplicates: true,legacyScan: true,ScanMode.LowLatency - State restoration —
restoreStateIdentifier: 'com.knocklace.ble'for background BLE - Event handling — Custom error codes (101 = unauthorized, 102 = powered off) are re-emitted as state change events
- Native behavior — Platform-specific scan filtering and callback types
Scan Configuration
bleManager.startDeviceScan(null, {
allowDuplicates: true, // Get repeated RSSI updates from same device
legacyScan: true, // Use legacy BLE scan (wider compatibility)
scanMode: ScanMode.LowLatency, // Max scan frequency
callbackType: ScanCallbackType.AllMatches, // (RSSI monitoring) or FirstMatch (discovery)
}, callback);
Two scan modes are used:
- Discovery scan (
ScanCallbackType.FirstMatch) — Finds new devices, stops when paired device is found - RSSI monitoring scan (
ScanCallbackType.AllMatches) — Continuous RSSI updates from paired device only
Device Discovery Flow
sequenceDiagram
participant App as ApplicationService
participant BLE as BleManager
participant Remote as Remote Config
participant Redux as Redux Store
App->>Remote: getRemoteConfig()
Remote-->>App: customConfigPerDevice[]
App->>App: updateKnownTags(knownTags)
App->>BLE: startDeviceScan(FirstMatch)
loop Each discovered device
BLE-->>App: (error, device)
App->>App: isKnownDevice(device.name)
alt Device is known
App->>App: find customConfig by knownAs
App->>App: setConnectedDevice(device)
App->>BLE: stopDeviceScan()
App->>Redux: dispatch(setPairedDevice)
App->>App: AsyncStorage.setItem('pairedDeviceId')
App->>App: monitorRssiForPairedDevice()
end
end
Device Identification
Devices are identified differently on each platform:
| Platform | Identification Field | Example |
|---|---|---|
| iOS | device.name or device.localName | "sprite6", "SPRITE8" |
| Android | device.id (MAC address) | "E6:4C:24:54:AC:45" |
The DeviceCompanionClass singleton maintains a list of known device names (from remote config knownTags) and checks scanned devices against this list. It normalizes names to lowercase for comparison.
Remote Config Device Profiles
Each known device has a calibration profile:
interface DeviceCustomConfig {
customConfigEnabled: boolean;
customName: string; // "OVAL 2", "CIRCLE", "SQUARE", etc.
jewelModel: number; // 0=circle, 1=square, 2=oval
knownAs: string; // BLE name (iOS) or MAC (Android)
rssiAt1Meter: number; // Calibration: expected RSSI at 1m (-60 to -90)
rssiPow?: number; // Path loss exponent (0.8 to 1.0)
}
Example devices from remoteConfig.json:
| customName | knownAs (iOS) | rssiAt1Meter | rssiPow | jewelModel |
|---|---|---|---|---|
| OVAL 2 | sprite6 | -80 | 0.8 | 2 |
| CIRCLE | SPRITE8 | -75 | 1.0 | 3 |
| OVAL | SPRITE9 | -85 | 1.0 | 2 |
| SQUARE | SPRITE10 | -90 | 1.0 | 1 |
| PAY4BRAIN | PAY4BRAIN | -70 | 1.0 | 2 |
RSSI Monitoring
Continuous Scan
Once paired, monitorRssiForPairedDevice() starts a continuous BLE scan filtered to the paired device's ID:
this.bleManager?.startDeviceScan(
null,
{
allowDuplicates: true,
legacyScan: true,
scanMode: ScanMode.LowLatency,
callbackType: ScanCallbackType.AllMatches,
},
(error, device) => {
if (device?.id === this.pairedDeviceId && device.rssi) {
this.emit(ApplicationEvent.OnRssiUpdate, device.rssi);
}
},
);
RSSI-to-Distance Conversion
The distance calculation uses a log-distance path loss model:
calculateDistance(rssi: number): number {
const txPower = this.deviceReference?.rssiAt1Meter ?? this.factor; // default: -79
const ratio = txPower - rssi;
const ratiof = Math.pow(10, (ratio * (this.deviceReference?.rssiPow ?? 1)) / 10);
const rawVal = Math.sqrt(ratiof);
return AppModule.filter(Math.ceil(rawVal), this.kalmanVariation);
}
Formula: distance = sqrt(10^((txPower - rssi) * pathLossExponent / 10))
Where:
txPower= RSSI at 1 meter (calibrated per device)rssi= current RSSI readingpathLossExponent= environment-specific constant (0.8-1.0)kalmanVariation= rolling average window size (3 on iOS, 7 on Android)
Native Kalman Filter
The raw distance value is passed to the native module for smoothing:
iOS (AppModule.swift):
func filterReading(calculatedDist: Double, variation: Double) -> NSNumber {
// Clamp to [0, 100]
// Handle NaN (use last valid reading)
// Maintain rolling window of 20 readings
// Deduplicate
// Take last N readings (N = variation)
// Return mean
}
Android (AppModuleModule.kt):
override fun filter(rssi: Double, variation: Double): Double {
// Clamp to [0, 100]
// Handle NaN (use last valid reading)
// Maintain rolling window of 10-20 readings
// Deduplicate via distinct()
// Take last N readings (N = variation)
// Return average
}
Distance Update Pipeline
graph LR
RSSI["Raw RSSI"] --> CALC["calculateDistance()"]
CALC --> NATIVE["Native filter()"]
NATIVE --> DEBOUNCE["Debounce (timeoutSecs)"]
DEBOUNCE --> REDUX["Redux: updatePairedDeviceApproximatedDistance"]
DEBOUNCE --> ONDIST["OnDistanceUpdate()"]
ONDIST --> THRESHOLD{"distance <= emergencyRange?"}
THRESHOLD -->|Yes| ALERT["fireYoutube()"]
THRESHOLD -->|No| RANGES["Check rangesConfig"]
RANGES --> NOTIF["showNotification()"]
Debounce Mechanism
A timeout (timeoutSecs) resets the distance to -1 if no RSSI updates are received:
this.debounceTimeoutId = setTimeout(() => {
AppModule.resetPositionsArray();
store.dispatch(Actions.updatePairedDeviceApproximatedDistance(-1));
}, this.timeoutSecs);
Default timeouts: 10s (iOS), 3s (Android). This handles cases where the device goes out of range or BLE connection is lost.
Notification Suppression
The system uses lastTreeDistances (last 3 readings) to suppress jitter:
let canDisplay = true;
this.lastTreeDistances.forEach(d => {
canDisplay = !(Math.abs(distance - d) > 2 || Math.abs(d - distance) > 2);
});
A notification only fires if the current distance is within 2m of the last 3 readings. This prevents false alerts from RSSI spikes.
Background BLE Scanning
Android
RNBackgroundActionsTask extends HeadlessJsTaskService:
- Creates a foreground service with persistent notification ("Alerta Sprite — Knocklace Collection is running in background")
- Uses
FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICEon API 33+ - Notification channel:
RN_BACKGROUND_ACTIONS_CHANNELwithIMPORTANCE_LOW - Starts via
reactApplicationContext.startService(intent)
iOS
BGProcessingTask registered with identifier com.knocklace.BgTask.ScanningForDevices:
- Currently a placeholder implementation
- The
BleManageris initialized withrestoreStateIdentifier: 'com.knocklace.ble'for state restoration
Event Flow
graph TB
subgraph "BLE Layer"
BM["BleManager"] --> STATE["onStateChange"]
BM --> SCAN["startDeviceScan callback"]
end
subgraph "ApplicationService"
STATE --> EMIT_STATE["emit(OnBleStateChange)"]
SCAN --> EMIT_RSSI["emit(OnRssiUpdate)"]
EMIT_RSSI --> CALC["calculateDistance()"]
CALC --> EMIT_DIST["emit(OnDistanceUpdate)"]
end
subgraph "Event Handlers"
EMIT_STATE --> H_STATE["Handle PoweredOn/Off/Unknown"]
EMIT_DIST --> H_DIST["Handle distance update"]
H_DIST --> REDUX1["dispatch(updatePairedDeviceApproximatedDistance)"]
H_DIST --> H_ALERT["OnDistanceUpdate()"]
end
subgraph "Alert System"
H_ALERT --> FIRE{"Emergency range?"}
FIRE -->|Yes| YOUTUBE["fireYoutube()"]
FIRE -->|No| RANGE["Check rangesConfig"]
RANGE --> NOTIFY["displayNotification()"]
end
Comparison with CAMDOM
| Aspect | Sprite Mobile | CAMDOM |
|---|---|---|
| BLE Pattern | Peripheral scanning (1 phone → 1 device) | Mesh networking (N devices ↔ N devices) |
| Protocol | Standard BLE advertising | Custom JSI C++ state machine |
| Data Exchanged | RSSI only (no GATT characteristics used) | Bidirectional consent signals |
| Connection | No GATT connection — scan-only | Persistent connections with characteristic reads/writes |
| Multi-device | Single device at a time | Multiple devices in mesh topology |
| Distance Model | Log-distance path loss + Kalman filter | Mesh proximity detection |
| Background | Foreground service (Android) / BGProcessingTask (iOS) | Foreground service + QUIC/TCP multiplexing |
| Native Code | Swift (Kalman filter) / Kotlin (overlay, BG tasks) | Full JSI C++ bridge (race condition exploitation) |
| Library | Forked react-native-ble-plx | Custom BLE implementation |
Key Difference: Scan-Only vs. Connected
Sprite never establishes a GATT connection to the Knocklace device. It only scans for advertising packets and reads the RSSI value. This is a fundamentally different approach from CAMDOM, which establishes persistent BLE connections and reads/writes GATT characteristics for bidirectional communication.
The scan-only approach means:
- Lower battery consumption
- No pairing required (at OS level)
- No GATT service/characteristic discovery
- RSSI-only data (no payload)
- Simpler error handling (scan failures vs. connection drops)
Key Difference: Single Device vs. Mesh
CAMDOM's BLE mesh requires:
- Multiple simultaneous connections
- Custom protocol for device discovery and signal relay
- State synchronization across devices
- JSI C++ bridge for low-latency state management
Sprite's single-device model allows:
- Simple Redux state (one
pairedDeviceobject) - No state synchronization
- JavaScript-level event handling is sufficient
- TurboModule for native bridge (adequate for the use case)
Error Handling
BLE errors are mapped to state changes:
| Error Code | Meaning | Action |
|---|---|---|
| 101 | Unauthorized | Emit OnBleStateChange with State.Unauthorized → retry permission request after 2s |
| 102 | Powered off | Emit OnBleStateChange with State.PoweredOff → stop all scans |
| Other | Scan failure | Stop foreground scan, log error |
State Restoration
The BleManager is initialized with restoreStateIdentifier: 'com.knocklace.ble', enabling iOS BLE state restoration. When the app is relaunched after being terminated by the system, the BLE manager can restore its previous state and resume scanning.
On Android, state restoration is handled by the pairedDeviceId stored in AsyncStorage. On app launch, the service checks for a stored device ID and resumes monitoring if found.
Performance Considerations
- Scan frequency:
ScanMode.LowLatencyprovides the fastest scan interval (~100ms) at the cost of higher battery usage - Kalman filter: Native implementation avoids JS bridge overhead for the most frequent calculation
- Debounce: Prevents UI jank from rapid RSSI fluctuations
- Notification suppression: Last-3-readings check prevents notification spam
- Memoization:
RadarBaseAndEffectandBackgroundVideoare wrapped inReact.memo
Cross-References
- sprite-mobile — Hub overview
- sprite-mobile-architecture — Full architecture context
- camdom — CAMDOM's BLE mesh approach
- camdom-architecture — CAMDOM's JSI C++ state machine for BLE