Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 69 additions & 15 deletions README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,7 @@ class BluetoothLe : Plugin() {

val services = (call.getArray("services", JSArray()) as JSArray).toList<String>()
val manufacturerDataArray = call.getArray("manufacturerData", JSArray())
val serviceDataArray = call.getArray("serviceData", JSArray())
val name = call.getString("name", null)

try {
Expand All @@ -825,6 +826,57 @@ class BluetoothLe : Plugin() {
filters.add(filter.build())
}

// Service Data Handling (for filtering by service data like OpenDroneID)
serviceDataArray?.let {
for (i in 0 until it.length()) {
val serviceDataObject = it.getJSONObject(i)

val serviceUuid = serviceDataObject.getString("serviceUuid")
val servicePuuid = ParcelUuid.fromString(serviceUuid)

val dataPrefix = if (serviceDataObject.has("dataPrefix")) {
val dataPrefixObject = serviceDataObject.getJSONObject("dataPrefix")
val byteLength = dataPrefixObject.length()

ByteArray(byteLength).apply {
for (idx in 0 until byteLength) {
val key = idx.toString()
this[idx] = (dataPrefixObject.getInt(key) and 0xFF).toByte()
}
}
} else null

val mask = if (serviceDataObject.has("mask")) {
val maskObject = serviceDataObject.getJSONObject("mask")
val byteLength = maskObject.length()

ByteArray(byteLength).apply {
for (idx in 0 until byteLength) {
val key = idx.toString()
this[idx] = (maskObject.getInt(key) and 0xFF).toByte()
}
}
} else null

val filterBuilder = ScanFilter.Builder()

if (dataPrefix != null && mask != null) {
filterBuilder.setServiceData(servicePuuid, dataPrefix, mask)
} else if (dataPrefix != null) {
filterBuilder.setServiceData(servicePuuid, dataPrefix)
} else {
// Set service data filter without data (just match the service UUID)
filterBuilder.setServiceData(servicePuuid, byteArrayOf())
}

if (name != null) {
filterBuilder.setDeviceName(name)
}

filters.add(filterBuilder.build())
}
}

// Manufacturer Data Handling (with optional parameters)
manufacturerDataArray?.let {
for (i in 0 until it.length()) {
Expand Down
48 changes: 48 additions & 0 deletions ios/Plugin/DeviceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
private var shouldShowDeviceList = false
private var allowDuplicates = false
private var manufacturerDataFilters: [ManufacturerDataFilter]?
private var serviceDataFilters: [ServiceDataFilter]?

static let serviceUUID = CBUUID(string: "0000fffa-0000-1000-8000-00805f9b34fb")
static let odidAdCode: [UInt8] = [ 0x0D ]

init(_ viewController: UIViewController?, _ displayStrings: [String: String], _ callback: @escaping Callback) {
super.init()
Expand Down Expand Up @@ -81,6 +85,7 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
_ name: String?,
_ namePrefix: String?,
_ manufacturerDataFilters: [ManufacturerDataFilter]?,
_ serviceDataFilters: [ServiceDataFilter]?,
_ allowDuplicates: Bool,
_ shouldShowDeviceList: Bool,
_ scanDuration: Double?,
Expand All @@ -97,6 +102,7 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
self.deviceNameFilter = name
self.deviceNamePrefixFilter = namePrefix
self.manufacturerDataFilters = manufacturerDataFilters
self.serviceDataFilters = serviceDataFilters

if shouldShowDeviceList {
self.showDeviceList()
Expand Down Expand Up @@ -156,6 +162,7 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
guard self.passesNameFilter(peripheralName: peripheral.name) else { return }
guard self.passesNamePrefixFilter(peripheralName: peripheral.name) else { return }
guard self.passesManufacturerDataFilter(advertisementData) else { return }
guard self.passesServiceDataFilter(advertisementData) else { return }

let device: Device
if self.allowDuplicates, let knownDevice = discoveredDevices.first(where: { $0.key == peripheral.identifier.uuidString })?.value {
Expand Down Expand Up @@ -348,6 +355,47 @@ class DeviceManager: NSObject, CBCentralManagerDelegate {
return false // If none matched, return false
}

private func passesServiceDataFilter(_ advertisementData: [String: Any]) -> Bool {
guard let filters = self.serviceDataFilters, !filters.isEmpty else {
return true // No filters means everything passes
}

guard let serviceDataDict = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] else {
return false // If there's no service data, fail
}

for filter in filters {
guard let serviceData = serviceDataDict[filter.serviceUuid] else {
continue // Skip if service UUID does not match
}

if let dataPrefix = filter.dataPrefix {
if serviceData.count < dataPrefix.count {
continue // Service data too short, does not match
}

if let mask = filter.mask {
var matches = true
for i in 0..<dataPrefix.count {
if (serviceData[i] & mask[i]) != (dataPrefix[i] & mask[i]) {
matches = false
break
}
}
if matches {
return true
}
} else if serviceData.starts(with: dataPrefix) {
return true
}
} else {
return true // Service UUID matched, and no dataPrefix required
}
}

return false // If none matched, return false
}

private func resolve(_ key: String, _ value: String) {
let callback = self.callbackMap[key]
if callback != nil {
Expand Down
48 changes: 48 additions & 0 deletions ios/Plugin/Plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ struct ManufacturerDataFilter {
let mask: Data?
}

struct ServiceDataFilter {
let serviceUuid: CBUUID
let dataPrefix: Data?
let mask: Data?
}

@objc(BluetoothLe)
public class BluetoothLe: CAPPlugin {
typealias BleDevice = [String: Any]
Expand Down Expand Up @@ -117,12 +123,14 @@ public class BluetoothLe: CAPPlugin {
let name = call.getString("name")
let namePrefix = call.getString("namePrefix")
let manufacturerDataFilters = self.getManufacturerDataFilters(call)
let serviceDataFilters = self.getServiceDataFilters(call)

deviceManager.startScanning(
serviceUUIDs,
name,
namePrefix,
manufacturerDataFilters,
serviceDataFilters,
false,
true,
30,
Expand Down Expand Up @@ -151,12 +159,14 @@ public class BluetoothLe: CAPPlugin {
let namePrefix = call.getString("namePrefix")
let allowDuplicates = call.getBool("allowDuplicates", false)
let manufacturerDataFilters = self.getManufacturerDataFilters(call)
let serviceDataFilters = self.getServiceDataFilters(call)

deviceManager.startScanning(
serviceUUIDs,
name,
namePrefix,
manufacturerDataFilters,
serviceDataFilters,
allowDuplicates,
false,
nil,
Expand Down Expand Up @@ -567,6 +577,44 @@ public class BluetoothLe: CAPPlugin {
return manufacturerDataFilters
}

private func getServiceDataFilters(_ call: CAPPluginCall) -> [ServiceDataFilter]? {
guard let serviceDataArray = call.getArray("serviceData") else {
return nil
}

var serviceDataFilters: [ServiceDataFilter] = []

for index in 0..<serviceDataArray.count {
guard let dataObject = serviceDataArray[index] as? JSObject,
let serviceUuidString = dataObject["serviceUuid"] as? String else {
// Invalid or missing service UUID
return nil
}

let serviceUuid = CBUUID(string: serviceUuidString)

let dataPrefix: Data? = {
guard let prefixArray = dataObject["dataPrefix"] as? [Int] else { return nil }
return Data(prefixArray.map { UInt8($0 & 0xFF) })
}()

let mask: Data? = {
guard let maskArray = dataObject["mask"] as? [Int] else { return nil }
return Data(maskArray.map { UInt8($0 & 0xFF) })
}()

let serviceDataFilter = ServiceDataFilter(
serviceUuid: serviceUuid,
dataPrefix: dataPrefix,
mask: mask
)

serviceDataFilters.append(serviceDataFilter)
}

return serviceDataFilters
}

private func getDevice(_ call: CAPPluginCall, checkConnection: Bool = true) -> Device? {
guard let deviceId = call.getString("deviceId") else {
call.reject("deviceId required.")
Expand Down
27 changes: 27 additions & 0 deletions src/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ export interface RequestBleDeviceOptions {
* https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth/requestDevice#manufacturerdata
*/
manufacturerData?: ManufacturerDataFilter[];
/**
* Allow scanning for devices with specific service data.
* Service data is data associated with a specific service UUID in the advertisement packet.
* Useful for protocols like OpenDroneID, EddyStone, and Open Beacon.
*/
serviceData?: ServiceDataFilter[];
}

/**
Expand Down Expand Up @@ -114,6 +120,27 @@ export interface ManufacturerDataFilter {
mask?: Uint8Array;
}

export interface ServiceDataFilter {
/**
* Service UUID to filter by. The service data must be associated with this UUID.
* UUIDs have to be specified as 128 bit UUID strings,
* e.g. '0000fffa-0000-1000-8000-00805f9b34fb'
*/
serviceUuid: string;

/**
* Prefix to match in the service data field.
* For example, OpenDroneID uses [0x0D] as the advertisement code.
*/
dataPrefix?: Uint8Array;

/**
* Set filter on partial service data. For any bit in the mask, set it to 1 if it needs to match the one in service data, otherwise set it to 0.
* The `mask` must have the same length as dataPrefix.
*/
mask?: Uint8Array;
}

export interface BleDevice {
/**
* ID of the device, which will be needed for further calls.
Expand Down
69 changes: 69 additions & 0 deletions src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ export class BluetoothLeWeb extends WebPlugin implements BluetoothLePlugin {
const deviceId = event.device.id;
this.deviceMap.set(deviceId, event.device);
const isNew = !this.discoveredDevices.has(deviceId);

// Apply service data filtering client-side (Web Bluetooth API doesn't support it in scan filters)
if (this.requestBleDeviceOptions?.serviceData && !this.matchesServiceDataFilter(event)) {
return;
}

if (isNew || this.requestBleDeviceOptions?.allowDuplicates) {
this.discoveredDevices.set(deviceId, true);
const device = this.getBleDevice(event.device);
Expand Down Expand Up @@ -381,9 +387,72 @@ export class BluetoothLeWeb extends WebPlugin implements BluetoothLePlugin {
manufacturerData: [manufacturerData],
});
}
// Note: Web Bluetooth API does not support service data in scan filters.
// Service data filtering will be done client-side in onAdvertisementReceived.
// We still accept serviceData in options for API consistency across platforms.
return filters;
}

private matchesServiceDataFilter(event: BluetoothAdvertisingEvent): boolean {
const filters = this.requestBleDeviceOptions?.serviceData;
if (!filters || filters.length === 0) {
return true; // No filters, accept all
}

if (!event.serviceData) {
return false; // No service data in advertisement
}

// Check if any filter matches (OR logic)
for (const filter of filters) {
const serviceData = event.serviceData.get(filter.serviceUuid);
if (!serviceData) {
continue; // This filter doesn't match, try next
}

// If we have service data for this UUID
if (!filter.dataPrefix) {
return true; // Filter matched by service UUID alone
}

// Check data prefix
const data = new Uint8Array(serviceData.buffer);
const prefix = filter.dataPrefix;

if (data.length < prefix.length) {
continue; // Data too short
}

// Apply mask if provided
if (filter.mask) {
let matches = true;
for (let i = 0; i < prefix.length; i++) {
if ((data[i] & filter.mask[i]) !== (prefix[i] & filter.mask[i])) {
matches = false;
break;
}
}
if (matches) {
return true;
}
} else {
// Check if data starts with prefix
let matches = true;
for (let i = 0; i < prefix.length; i++) {
if (data[i] !== prefix[i]) {
matches = false;
break;
}
}
if (matches) {
return true;
}
}
}

return false; // No filter matched
}

private getDeviceFromMap(deviceId: string): BluetoothDevice {
const device = this.deviceMap.get(deviceId);
if (device === undefined) {
Expand Down
5 changes: 3 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"declaration": true,
"esModuleInterop": true,
"inlineSources": true,
"lib": ["dom", "es2017"],
"lib": ["dom", "es2018"],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the changes to this file mandatory? I can't see any new browser APIs being used in the code you've added...

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change to es2018 is required because the code uses Promise.prototype.finally() in src/timeout.ts:8. This method was introduced in ES2018/ES2019 spec.

Without this change, the TypeScript compiler throws

'error TS2550: Property 'finally' does not exist on type 'Promise'.
Do you need to change your target library? Try changing the 'lib' compiler option to 'es2018' or later.'

The finally() method is used in the timeout utility to ensure cleanup (clearing the timeout) happens regardless of whether the promise resolves or rejects. This is the only ES2018 feature being used, but it's essential for the proper cleanup logic.

"module": "esnext",
"moduleResolution": "node",
"noFallthroughCasesInSwitch": true,
Expand All @@ -14,7 +14,8 @@
"pretty": true,
"sourceMap": true,
"strict": true,
"target": "es2017"
"target": "es2017",
"types": ["jest", "@types/web-bluetooth"]
},
"files": ["src/index.ts"]
}