Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## 3.0.0
* **Breaking:** Add `QueueType.auto` which auto-selects the best queueing strategy per platform: Android uses a per-device queue, all other platforms run commands in parallel. It is now the default for both `UniversalBle` and `UniversalBlePeripheral`, replacing the previous `QueueType.global` default.
* **Breaking:** Device IDs are now emitted in lower-case on every platform (scan results, connection/value/pairing/connection-parameter callbacks and streams). Previously each platform reported its native case — Android upper-cased MACs, Windows/WinRT lower-cased them. IDs are now canonicalised to lower-case throughout the Dart layer; the native side converts back to the case it requires at its boundary (Android's `getRemoteDevice` needs upper-case). Callers that stored or compared an emitted ID by exact case (e.g. an Android upper-case MAC) must now lower-case it, or compare case-insensitively. Web is exempt: Web Bluetooth IDs are opaque, case-sensitive browser tokens rather than addresses, so they are emitted and matched verbatim. Follow-up to the case-insensitive matching in 2.1.1.
* **Breaking:** The same applies to peripheral mode — central device IDs in `UniversalBlePeripheral` streams, read/write request handlers, and `getSubscribedClients` results are emitted in lower-case, and IDs passed to `updateCharacteristicValue` / `getMaximumNotifyLength` are accepted in any case.
* iOS/macOS: Handle write-without-response transmit buffer backpressure
* iOS/macOS: complete concurrent reads, descriptor operations, notification changes, and RSSI reads one callback at a time.

Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,12 @@ UniversalBlePeripheral.mtuChangedStream.listen(
- Windows peripheral advertising does not expose all advertising payload customization options from Android/Apple stacks.
- iOS/macOS setup (including required `Info.plist` keys for peripheral usage) is documented in [Permissions → iOS / macOS](#ios--macos).

## Device ID Format

A device ID is a case-insensitive address on every native platform — a MAC on Android/Windows/Linux, a UUID on Apple — but each platform reports it in its own case (Android upper-cases MACs, Windows lower-cases them). Universal BLE canonicalises them, so **device IDs are emitted in lower-case** in scan results and in every connection, value, pairing and connection-parameter callback or stream. You may pass an ID back in any case; conversion to whatever the native side requires happens internally.

Web is the exception: Web Bluetooth IDs are opaque, case-sensitive browser tokens rather than addresses, so they are emitted exactly as the browser reports them and must be compared exactly.

## UUID Format Agnostic

Universal BLE is agnostic to the UUID format of services and characteristics regardless of the platform the app runs on. When passing a UUID, you can pass it in any format (long/short) or character case (upper/lower case) you want. Universal BLE will take care of necessary conversions, across all platforms, so that you don't need to worry about underlying platform differences.
Expand Down
10 changes: 8 additions & 2 deletions lib/src/extensions/ble_device_extension.dart
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ extension BleDeviceExtension on BleDevice {
timeout: timeout,
queueId: queueId,
);
CacheHandler.instance.saveServices(deviceId, servicesCache);
CacheHandler.instance.saveServices(
UniversalBle.canonicalDeviceId(deviceId),
servicesCache,
);
return servicesCache;
}

Expand All @@ -123,7 +126,10 @@ extension BleDeviceExtension on BleDevice {
}) async {
List<BleService> discoveredServices = [];
if (preferCached) {
discoveredServices = CacheHandler.instance.getServices(deviceId) ?? [];
discoveredServices = CacheHandler.instance.getServices(
UniversalBle.canonicalDeviceId(deviceId),
) ??
[];
}
if (discoveredServices.isEmpty) {
discoveredServices = await discoverServices(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:typed_data';

import 'package:universal_ble/src/universal_ble.g.dart';
import 'package:universal_ble/src/utils/device_id.dart';
import 'package:universal_ble/src/utils/universal_ble_stream_controller.dart';
import 'package:universal_ble/universal_ble.dart';

Expand All @@ -11,9 +12,8 @@ abstract class UniversalBlePeripheralPlatform {
_blePeripheralStreamHandler.advertisingStateStreamController.stream;

Stream<BlePeripheralCharacteristicSubscriptionChanged>
get characteristicSubscriptionStream => _blePeripheralStreamHandler
.characteristicSubscriptionStreamController
.stream;
get characteristicSubscriptionStream => _blePeripheralStreamHandler
.characteristicSubscriptionStreamController.stream;

Stream<BlePeripheralConnectionStateChanged> get connectionStateStream =>
_blePeripheralStreamHandler.connectionStateStreamController.stream;
Expand Down Expand Up @@ -81,13 +81,23 @@ abstract class UniversalBlePeripheralPlatform {
BlePeripheralCharacteristicSubscriptionChanged event,
) {
_blePeripheralStreamHandler.characteristicSubscriptionStreamController.add(
event,
BlePeripheralCharacteristicSubscriptionChanged(
deviceId: DeviceId.address(event.deviceId).canonical,
characteristicId: event.characteristicId,
isSubscribed: event.isSubscribed,
name: event.name,
),
);
}

/// Push connection state update to stream listeners.
void updateConnectionState(BlePeripheralConnectionStateChanged event) {
_blePeripheralStreamHandler.connectionStateStreamController.add(event);
_blePeripheralStreamHandler.connectionStateStreamController.add(
BlePeripheralConnectionStateChanged(
DeviceId.address(event.deviceId).canonical,
event.connected,
),
);
}

/// Push service added update to stream listeners.
Expand All @@ -97,7 +107,12 @@ abstract class UniversalBlePeripheralPlatform {

/// Push MTU update to stream listeners.
void updateMtu(BlePeripheralMtuChanged event) {
_blePeripheralStreamHandler.mtuChangedStreamController.add(event);
_blePeripheralStreamHandler.mtuChangedStreamController.add(
BlePeripheralMtuChanged(
DeviceId.address(event.deviceId).canonical,
event.mtu,
),
);
}

/// Called when this platform implementation is being replaced.
Expand Down Expand Up @@ -216,8 +231,7 @@ class _BlePeripheralStreamHandler {
UniversalBleStreamController<BlePeripheralAdvertisingStateChanged>();
final characteristicSubscriptionStreamController =
UniversalBleStreamController<
BlePeripheralCharacteristicSubscriptionChanged
>();
BlePeripheralCharacteristicSubscriptionChanged>();
final connectionStateStreamController =
UniversalBleStreamController<BlePeripheralConnectionStateChanged>();
final serviceAddedStreamController =
Expand Down
65 changes: 36 additions & 29 deletions lib/src/interfaces/universal_ble_platform_interface.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:universal_ble/src/utils/cache_handler.dart';
import 'package:universal_ble/src/utils/device_id.dart';
import 'package:universal_ble/src/utils/universal_ble_stream_controller.dart';
import 'package:universal_ble/src/utils/universal_logger.dart';
import 'package:universal_ble/universal_ble.dart';
Expand Down Expand Up @@ -143,43 +144,52 @@ abstract class UniversalBlePlatform {
Stream<AvailabilityState> get availabilityStream =>
_availabilityStreamController.stream;

// A BLE device id is a case-insensitive identifier (a MAC on Android/Windows/Linux, a UUID on Apple), but
// platforms report it in different cases — Android upper-cases MACs, Windows/WinRT lower-cases them
// (`mac_address_to_str` emits lower-case hex). So we (a) match the event streams case-insensitively, and
// (b) key all per-device state by a canonical lower-case id (see updatePairingState /
// updateConnectionParameters / CacheHandler) so a device reported in two cases can't split across map
// entries. Emitted device ids are left AS the platform reports them, so this is non-breaking for consumers.
// Hot paths short-circuit on an exact match before lower-casing.
/// Whether this platform reports device ids as Bluetooth *addresses* — a MAC on
/// Android/Windows/Linux, a UUID on Apple — which are case-insensitive and which platforms
/// report in different cases (Android upper-cases MACs, Windows/WinRT lower-cases them). Ids are
/// therefore canonicalised: the update* handlers below canonicalise on ingestion, so every
/// stream event, callback and per-device map key is canonical, and the stream filters
/// canonicalise the query so consumers may still pass an id in any case. Native calls take the
/// other form the same [DeviceId] carries; see that class for both conversions.
///
/// Web overrides this to `false`: Web Bluetooth ids are opaque, case-sensitive browser tokens
/// rather than addresses, so they are emitted and matched verbatim.
bool get hasAddressDeviceIds => true;

/// [deviceId] in the two forms this platform needs. Emission and matching both go through it, so
/// an override of [hasAddressDeviceIds] cannot leave the two halves disagreeing.
DeviceId _deviceId(String deviceId) =>
DeviceId.of(deviceId, isAddress: hasAddressDeviceIds);


Stream<bool> connectionStream(String deviceId) {
final target = deviceId.toLowerCase();
final target = _deviceId(deviceId).canonical;
return bleConnectionUpdateStreamController.stream
.where((e) => e.deviceId == deviceId || e.deviceId.toLowerCase() == target)
.where((e) => e.deviceId == target)
.map((e) => e.isConnected);
}

Stream<Uint8List> characteristicValueStream(
String deviceId,
String characteristicId,
) {
final target = deviceId.toLowerCase();
final target = _deviceId(deviceId).canonical;
characteristicId = BleUuidParser.string(characteristicId);
return _valueStreamController.stream
.where((e) {
return (e.deviceId == deviceId || e.deviceId.toLowerCase() == target) &&
e.characteristicId == characteristicId;
})
.where((e) => e.deviceId == target && e.characteristicId == characteristicId)
.map((e) => e.value);
}

Stream<bool> pairingStateStream(String deviceId) {
final target = deviceId.toLowerCase();
final target = _deviceId(deviceId).canonical;
return _pairStateStreamController.stream
.where((e) => e.deviceId == deviceId || e.deviceId.toLowerCase() == target)
.where((e) => e.deviceId == target)
.map((e) => e.isPaired);
}

/// Update Handlers
void updateScanResult(BleDevice bleDevice) {
bleDevice.deviceId = _deviceId(bleDevice.deviceId).canonical;
_scanStreamController.add(bleDevice);

try {
Expand All @@ -188,6 +198,7 @@ abstract class UniversalBlePlatform {
}

void updateConnection(String deviceId, bool isConnected, [String? error]) {
deviceId = _deviceId(deviceId).canonical;
bleConnectionUpdateStreamController.add((
deviceId: deviceId,
isConnected: isConnected,
Expand All @@ -199,10 +210,9 @@ abstract class UniversalBlePlatform {
} catch (_) {}

if (!isConnected) {
// Clear per-device state by the canonical id so cleanup can't miss an entry stored under another case
// (CacheHandler normalizes internally).
// Clear per-device state (all keyed by the canonical id).
CacheHandler.instance.resetDeviceCache(deviceId);
_lastConnectionParametersMap.remove(deviceId.toLowerCase());
_lastConnectionParametersMap.remove(deviceId);
}
}

Expand All @@ -212,6 +222,7 @@ abstract class UniversalBlePlatform {
Uint8List value,
int? timestamp,
) {
deviceId = _deviceId(deviceId).canonical;
characteristicId = BleUuidParser.string(characteristicId);
// StandardMessageCodec decodes typed data as a view into the complete
// platform-message buffer. Normalize that view before exposing it so
Expand Down Expand Up @@ -246,11 +257,9 @@ abstract class UniversalBlePlatform {
}

void updatePairingState(String deviceId, bool isPaired) {
// Key by the canonical id so the same device reported in another case doesn't create a second entry and
// slip past this dedup. The emitted deviceId keeps the platform's case.
final key = deviceId.toLowerCase();
if (_pairStateMap[key] == isPaired) return;
_pairStateMap[key] = isPaired;
deviceId = _deviceId(deviceId).canonical;
if (_pairStateMap[deviceId] == isPaired) return;
_pairStateMap[deviceId] = isPaired;

_pairStateStreamController.add((deviceId: deviceId, isPaired: isPaired));

Expand All @@ -260,18 +269,16 @@ abstract class UniversalBlePlatform {
}

void updateConnectionParameters(BleConnectionParametersUpdated update) {
// Key by the canonical id (dropping the now-redundant last.deviceId == update.deviceId check, which would
// itself have failed across cases and broken dedup for a device reported in two cases).
final key = update.deviceId.toLowerCase();
final last = _lastConnectionParametersMap[key];
update.deviceId = _deviceId(update.deviceId).canonical;
final last = _lastConnectionParametersMap[update.deviceId];
if (last != null &&
last.interval == update.interval &&
last.latency == update.latency &&
last.supervisionTimeout == update.supervisionTimeout &&
last.status == update.status) {
return;
}
_lastConnectionParametersMap[key] = update;
_lastConnectionParametersMap[update.deviceId] = update;

try {
onConnectionParametersChange?.call(update);
Expand Down
27 changes: 21 additions & 6 deletions lib/src/universal_ble.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:universal_ble/src/universal_ble_pigeon/universal_ble_pigeon_chan
import 'package:universal_ble/src/universal_ble_web/universal_ble_web.dart';
import 'package:universal_ble/src/utils/ble_command_queue.dart';
import 'package:universal_ble/src/utils/cache_handler.dart';
import 'package:universal_ble/src/utils/device_id.dart';
import 'package:universal_ble/src/utils/universal_logger.dart';
import 'package:universal_ble/universal_ble.dart';

Expand Down Expand Up @@ -315,16 +316,31 @@ class UniversalBle {
);
}

/// [deviceId] in the form the active platform emits, matches and keys per-device state by.
///
/// The public API accepts an id in any case, so caller-supplied ids are canonicalised here —
/// at the boundary, where the platform's id kind is known — and everything downstream (the
/// service/subscription cache, stream matching) sees canonical ids only. All conversion lives
/// in [DeviceId].
@internal
static String canonicalDeviceId(String deviceId) =>
DeviceId.of(deviceId, isAddress: _platform.hasAddressDeviceIds).canonical;

/// Returns whether this app is currently subscribed to notifications/indications for [characteristic].
///
/// Subscription state is updated when [subscribeNotifications], [subscribeIndications],
/// or [unsubscribe] completes, and is automatically cleared when the device disconnects.
static bool isSubscribed(String deviceId, String characteristic) =>
CacheHandler.instance.isSubscribed(deviceId, characteristic);
CacheHandler.instance.isSubscribed(
canonicalDeviceId(deviceId),
characteristic,
);

/// Returns the list of characteristic UUIDs currently subscribed to on [deviceId].
static List<String> getSubscribedCharacteristics(String deviceId) =>
CacheHandler.instance.getSubscribedCharacteristics(deviceId);
CacheHandler.instance.getSubscribedCharacteristics(
canonicalDeviceId(deviceId),
);

/// Read a characteristic value.
/// On iOS and MacOS this command will also trigger [onValueChange] listener.
Expand Down Expand Up @@ -731,7 +747,7 @@ class UniversalBle {
Duration? timeout,
}) {
timeout ??= const Duration(seconds: 60);
final target = deviceId.toLowerCase();
final target = canonicalDeviceId(deviceId);
StreamSubscription? connectionSubscription;
Completer<bool> completer = Completer();

Expand All @@ -748,8 +764,7 @@ class UniversalBle {

connectionSubscription = _platform
.bleConnectionUpdateStreamController.stream
.where(
(e) => e.deviceId == deviceId || e.deviceId.toLowerCase() == target)
.where((e) => e.deviceId == target)
.listen(
(e) {
cancelSubscription();
Expand Down Expand Up @@ -794,7 +809,7 @@ class UniversalBle {
queueId: queueId,
);
CacheHandler.instance.updateSubscription(
deviceId,
canonicalDeviceId(deviceId),
characteristic,
bleInputProperty != BleInputProperty.disabled,
);
Expand Down
4 changes: 4 additions & 0 deletions lib/src/universal_ble_linux/universal_ble_linux.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:async';
import 'package:bluez/bluez.dart';
import 'package:flutter/services.dart';
import 'package:universal_ble/src/models/model_exports.dart';
import 'package:universal_ble/src/utils/device_id.dart';
import 'package:universal_ble/src/utils/universal_ble_error_parser.dart';
import 'package:universal_ble/src/utils/universal_ble_filter_util.dart';
import 'package:universal_ble/src/universal_ble.g.dart';
Expand Down Expand Up @@ -562,6 +563,9 @@ class UniversalBleLinux extends UniversalBlePlatform {

/// Get device by id from cache or from client
BlueZDevice? _getDeviceById(String deviceId) {
// Every Linux device resolution funnels through here, so this is where the id becomes the
// native form BlueZ addresses use; emitted ids and cache keys keep the canonical form.
deviceId = DeviceId.address(deviceId).native;
return _devices[deviceId] ??
_client.devices.cast<BlueZDevice?>().firstWhere(
(device) => device?.address == deviceId,
Expand Down
Loading
Loading