WikifitaGitHub live67e8de5
outro · sprite-mobile/sprite-mobile-ble

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

Baixar raw

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 parametersallowDuplicates: true, legacyScan: true, ScanMode.LowLatency
  • State restorationrestoreStateIdentifier: '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:

  1. Discovery scan (ScanCallbackType.FirstMatch) — Finds new devices, stops when paired device is found
  2. 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:

PlatformIdentification FieldExample
iOSdevice.name or device.localName"sprite6", "SPRITE8"
Androiddevice.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:

customNameknownAs (iOS)rssiAt1MeterrssiPowjewelModel
OVAL 2sprite6-800.82
CIRCLESPRITE8-751.03
OVALSPRITE9-851.02
SQUARESPRITE10-901.01
PAY4BRAINPAY4BRAIN-701.02

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 reading
  • pathLossExponent = 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_DEVICE on API 33+
  • Notification channel: RN_BACKGROUND_ACTIONS_CHANNEL with IMPORTANCE_LOW
  • Starts via reactApplicationContext.startService(intent)

iOS

BGProcessingTask registered with identifier com.knocklace.BgTask.ScanningForDevices:

  • Currently a placeholder implementation
  • The BleManager is initialized with restoreStateIdentifier: '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

AspectSprite MobileCAMDOM
BLE PatternPeripheral scanning (1 phone → 1 device)Mesh networking (N devices ↔ N devices)
ProtocolStandard BLE advertisingCustom JSI C++ state machine
Data ExchangedRSSI only (no GATT characteristics used)Bidirectional consent signals
ConnectionNo GATT connection — scan-onlyPersistent connections with characteristic reads/writes
Multi-deviceSingle device at a timeMultiple devices in mesh topology
Distance ModelLog-distance path loss + Kalman filterMesh proximity detection
BackgroundForeground service (Android) / BGProcessingTask (iOS)Foreground service + QUIC/TCP multiplexing
Native CodeSwift (Kalman filter) / Kotlin (overlay, BG tasks)Full JSI C++ bridge (race condition exploitation)
LibraryForked react-native-ble-plxCustom 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 pairedDevice object)
  • 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 CodeMeaningAction
101UnauthorizedEmit OnBleStateChange with State.Unauthorized → retry permission request after 2s
102Powered offEmit OnBleStateChange with State.PoweredOff → stop all scans
OtherScan failureStop 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

  1. Scan frequency: ScanMode.LowLatency provides the fastest scan interval (~100ms) at the cost of higher battery usage
  2. Kalman filter: Native implementation avoids JS bridge overhead for the most frequent calculation
  3. Debounce: Prevents UI jank from rapid RSSI fluctuations
  4. Notification suppression: Last-3-readings check prevents notification spam
  5. Memoization: RadarBaseAndEffect and BackgroundVideo are wrapped in React.memo

Cross-References