Skip to content
CARP Docs
Ask AISystemLightDark
On this page

Bluetooth scanning

The BLUETOOTH measure, which lists the Bluetooth Low Energy devices near the phone in periodic scan windows.

Copy MarkdownOpen inOpen in GitHubView as MarkdownOpen in Scira AIOpen in ChatGPTOpen in ClaudeOpen in Cursor

dk.cachet.carp.bluetooth is an event-based measure that runs on the phone on Android and iOS, through the flutter_blue_plus plugin. Add it as Measure(type: ConnectivitySamplingPackage.BLUETOOTH). By default, it scans for 10 seconds every 10 minutes and records one measurement per scan window.

The measure finds Bluetooth Low Energy (BLE) devices only. Devices that use only Bluetooth Classic, such as many headphones, speakers and keyboards, do not appear.

Add the measure to a protocol

Start the measure with an ImmediateTrigger, so that it scans in windows until the study pauses. To change the windows or filter the scan, set a BluetoothScanPeriodicSamplingConfiguration on the measure. This example scans for 15 seconds every 5 minutes and finds only devices that advertise the heart rate service (180D):

protocol.addTaskControl(
  ImmediateTrigger(),
  BackgroundTask(
    measures: [
      Measure(
        type: ConnectivitySamplingPackage.BLUETOOTH,
        samplingConfiguration: BluetoothScanPeriodicSamplingConfiguration( 
          interval: const Duration(minutes: 5),
          duration: const Duration(seconds: 15),
          withServices: ['180D'], // heart rate service
        ),
      ),
    ],
  ),
  phone,
);

Without filters, a PeriodicSamplingConfiguration with interval and duration works too.

Configuration

BluetoothScanPeriodicSamplingConfiguration has these properties:

PropertyTypeDefaultDescription
intervalDuration—Required. Time between the starts of two scan windows.
durationDuration—Required. Length of each scan window.
withServicesList<String>[]Service UUIDs to scan for. An empty list means no filter.
withRemoteIdsList<String>[]Device ids to scan for. An empty list means no filter.

Without a configuration, the measure scans for 10 seconds every 10 minutes, without filters. Longer and more frequent windows find more devices and use more battery. Keep interval longer than 6 seconds, because Android lets an app start at most five scans in 30 seconds.

Each entry in withServices must be a hexadecimal UUID: 16-bit such as 180D, 32-bit or 128-bit such as 0000180d-0000-1000-8000-00805f9b34fb, with or without dashes. Any other value, such as a service name, throws a FormatException when a window starts, and the measure records nothing.

The ids in withRemoteIds are the bluetoothDeviceId values from the data: MAC addresses on Android. On iOS, the ids change over time, so they are not stable filters there.

Scan windows

The measure scans in these stages:

  1. The first window starts one interval after the measure starts, not right away.
  2. During the window, the measure collects every advertisement it receives. Each device appears once, with the values of its latest advertisement.
  3. When the window ends, the measure stops the scan and records one measurement, with the window's start and end in startScan and endScan.
  4. If the scan fails, for example because Bluetooth is off or a permission is missing, the window records an error instead. The error has the __type dk.cachet.carp.error and a message that starts with Error scanning for bluetooth - .
  5. When the study pauses, the current window is dropped without a measurement.

Known issue

Each window after the first also lists the devices of the previous window, because flutter_blue_plus replays its latest scan result when the measure starts listening. A device that has left therefore appears in one more window, with its old rssi. Take this into account when you count devices per window.

Permissions

The measure needs these permissions, which CARP Mobile Sensing (CAMS) and flutter_blue_plus ask for. Bluetooth must also be turned on:

PlatformPermissionsWho asks
Android 12 or laterBLUETOOTH_SCAN and BLUETOOTH_CONNECTCAMS asks for BLUETOOTH_SCAN before the measure starts. flutter_blue_plus asks for BLUETOOTH_CONNECT when the first scan starts, unless the app asked for it before.
Android 11 and lowerBLUETOOTH and BLUETOOTH_ADMIN in the manifest, precise location and Location Servicesflutter_blue_plus asks for location when the first scan starts.
iOSBluetoothCAMS asks for Bluetooth access before the measure starts.

On Android, the measure starts only when the Bluetooth scan permission is granted. To ask for BLUETOOTH_CONNECT together with the other permissions, add it to a permissionRequester, as the quickstart does. For the manifest entries, see Installation.

Platform differences

AndroidiOS
  • bluetoothDeviceId is the MAC address of the device.
  • withServices and withRemoteIds filter in the operating system.
  • An app can start at most five scans in 30 seconds.
  • A scan without a service filter finds nothing while the app is in the background or the screen is off. The measure therefore collects only while the app is in the foreground. For what iOS allows in the background, see Apple's Core Bluetooth Background Processing for iOS Apps.
  • bluetoothDeviceId is a random UUID that iOS changes over time. It cannot be matched with Android ids or followed over long periods.
  • The first window after the app starts can record an error, because Bluetooth is not ready yet when the first scan starts.
  • iBeacons do not appear, because iOS shows them only through Core Location. To detect iBeacons, see Beacons.

Name hashing

The default privacy schema replaces bluetoothDeviceName and advertisementName of every device with their SHA-1 hashes. Empty names are hashed too: the SHA-1 hash of an empty string is da39a3ee5e6b4b0d3255bfef95601890afd80709.

bluetoothDeviceId is stored as collected, so on Android the MAC addresses of the devices stay in the data. Turn the schema on with privacySchemaName: PrivacySchema.DEFAULT on the SmartphoneStudyProtocol.

Data

Each measurement holds a Bluetooth object with the __type dk.cachet.carp.bluetooth:

FieldTypeDescription
startScanDateTimeStart of the scan window, in the phone's local time without a time zone offset.
endScanDateTime?End of the scan window, in local time.
scanResultList<BluetoothDevice>The devices found, each once. Empty when no device was found.

Each entry in scanResult is a BluetoothDevice:

FieldTypeDescription
advertisementNameStringThe local name in the device's advertisement, or an empty string. Hashed with the default privacy schema.
bluetoothDeviceIdStringThe device id: the MAC address on Android, a changing UUID on iOS.
bluetoothDeviceNameStringThe name that the phone's operating system knows for the device, or an empty string. Hashed with the default privacy schema.
connectableboolWhether the device accepts connections.
txPowerLevelint?Advertised transmit power in dBm. Left out of the JSON when the device does not advertise it.
rssiintReceived signal strength in dBm. Values closer to 0 mean a stronger signal.

The measurement's sensorStartTime is the end of the window, when the measurement is recorded. On Android, without the privacy schema, the data looks like this:

{
  "__type": "dk.cachet.carp.bluetooth",
  "startScan": "2026-10-26T15:30:00.004512",
  "endScan": "2026-10-26T15:30:10.011873",
  "scanResult": [
    {
      "advertisementName": "Polar H10 1234ABCD",
      "bluetoothDeviceId": "A0:9E:1A:12:34:56",
      "bluetoothDeviceName": "Polar H10 1234ABCD",
      "connectable": true,
      "txPowerLevel": 4,
      "rssi": -67
    }
  ]
}

For the measurement envelope around the data, see Measurements and data.

Quick links

Page · DocumentationCARP DocumentationPage · DocumentationIntroductionPage · DocumentationQuickstartPage · DocumentationTerminologyPage · DocumentationArchitecture