ble_link_kit 1.0.0
ble_link_kit: ^1.0.0 copied to clipboard
Flutter BLE connection framework for multi-protocol scanning, generic GATT transfer, framed channels, provisioning, and resumable OTA.
ble_link_kit #
A BLE connection management framework for Flutter: multi-protocol device discovery, generic GATT data transfer, reliable framed channels, Wi-Fi provisioning, resumable OTA, and power profiles.
Device UUIDs, advertising signatures, and protocol operation values are supplied by the application; the package contains no product-specific values.
Installation and quick start #
Add ble_link_kit: ^1.0.0, then import
package:ble_link_kit/ble_link_kit.dart. The package requires Dart ^3.9.2
and a Flutter release that bundles a compatible Dart SDK; the declared Flutter
constraint is >=3.3.0. Native targets require Android API 23 or iOS 13.
final api = BleLinkKit();
await api.requestPermissions();
api.scanEvents.listen((event) => print(event.device));
await api.scan(const ScanOptions(timeout: Duration(seconds: 10)));
1.0.0 breaking changes #
Release 1.0.0 makes secure handoff require authenticated owner
credentials with handoff scope and a non-empty advertised target list. Signed
advertisements use the profile/carrier-bound v2 HMAC domain and at least an
8-byte tag. Platform implementers and exhaustive BleErrorCode switches must
be updated for all new APIs and values.
The concrete MethodChannelBleProvision is no longer a supported barrel export;
the neutral OpaqueTransportRoute enum remains public for platform implementers.
setSecurityRequirement now only enables
requireSymmetric; clearing it requires the separate, auditable
clearSecurityRequirement(deviceId, confirmInsecureDowngrade: true) call.
Raw-key SecureSession.client/device construction is no longer public.
The security requirement is synchronized per device to Android/iOS. Native
code rejects plaintext BLE/SPP provisioning and all payload-bearing raw writes
or framed sends for that device with
securityTierDowngrade. Opening a secure session does not unlock those legacy
plaintext routes; encrypted provisioning uses the P2 secure-frame path. This
gate also covers direct lib/src/platform.dart calls and is shared by plugin
instances. The requirement survives plugin recreation and process restart;
confirmed downgrade, application-data removal, or uninstall clears it. Under
the strongest threat model (malicious same-process code plus
a rooted/jailbroken host), firmware must also reject unencrypted provisioning.
The platform interface no longer contains credential-aware provision() or
sppProvision() methods. They bypassed facade secure-channel, generation, and
ownership policy even when importing lib/src directly. Ordinary applications
already using BleLinkKit are unaffected. Platform-interface implementers must
replace those methods with the neutral sendOpaquePayload(route, bytes)
transport primitive; credential encoding remains exclusively in BleLinkKit.
Secure handoff descriptors intentionally expose next-hop credentials to the
application, and HandoffDescriptor.toMap() contains plaintext PSK, token, or
blob values. Do not log, persist, send to analytics/crash reporting, or retain
these values longer than needed. toString() remains redacted; Dart cannot
guarantee heap zeroization.
Features #
- Register multiple independent scan signatures and see every matching profile ID.
- Access advertised service UUIDs, manufacturer data, service data, RSSI, and transmit power.
- Connect without configuring a provisioning protocol, then discover, read, write, subscribe, and inspect the negotiated MTU through generic GATT APIs.
- Optionally transfer fragmented, CRC16-checked messages over arbitrary writable/notifiable characteristic pairs.
- Apply one power policy to scanning, Android connection priority, and Android PHY selection.
- Provision Wi-Fi and perform acknowledged, CRC32-verified, resumable firmware updates.
- Restore CoreBluetooth state on iOS, run connected-device foreground work on Android, and optionally use Android RFCOMM SPP for provisioning.
Platform support #
| Capability | Android | iOS |
|---|---|---|
| Multi-profile BLE discovery and raw advertisement fields | Yes | Yes |
| Generic GATT discovery/read/write/notifications | Yes | Yes |
| Fragmented framed channels with CRC16 | Yes | Yes |
| Wi-Fi provisioning and resumable CRC32 OTA | Yes | Yes |
| Explicit connection priority / PHY control | Yes / Yes | No / No |
| Background connection support | Foreground service | CoreBluetooth restoration |
| RFCOMM SPP provisioning | Yes | No (unsupported) |
Use await api.capabilities before presenting platform-specific controls. Capability flags report BLE, OTA, SPP, PHY control, connection-priority control, and opportunistic scanning; they do not replace characteristic discovery or property checks.
Session handshake #
After connecting and configuring the command/event characteristic profile, call
openSession(deviceId) to exchange a structured device descriptor and select
the highest mutually supported protocol version. The returned immutable
SessionContext exposes the negotiated version, firmware/hardware descriptor,
enabled capabilities, handoff transports, and supports(...) checks. Session
lifecycle updates also appear on sessionEvents as opened or failed.
Session-open is additive: applications that do not call it keep the 0.4.0 scan,
provisioning, OTA, capabilities, and raw-GATT behavior. Session-open itself is
plaintext; securityTier reports whether P2 can establish an authenticated
encrypted channel. none supports only the legacy plaintext path.
Multi-protocol discovery #
ScanProfile is independent of ProvisionProtocolProfile. A profile matches when any supplied signature matches; if all three condition lists are empty, it explicitly matches every device. matchedProfileIds contains all matches. Registering an empty profile list restores the 0.2.0 discovery behavior.
Raw advertisement fields are added only after a non-empty profile list is registered. Use ScanProfile(id: 'all') as a catch-all when unfiltered advertisement payloads are required. Each scan uses an immutable profile snapshot; configureScanProfiles called during scanning takes effect on the next scan.
final api = BleLinkKit();
await api.configureScanProfiles([
ScanProfile(
id: 'environment-sensor',
serviceUuids: ['181A'],
namePrefixes: ['Env-'],
),
ScanProfile(
id: 'vendor-beacon',
manufacturerIds: [0x1234],
),
]);
await api.requestPermissions();
api.scanEvents.listen((event) {
final device = event.device;
if (device == null) return;
print(device.matchedProfileIds);
print(device.serviceUuids);
print(device.manufacturerData); // Map<int, Uint8List>
print(device.serviceData); // Map<String, Uint8List>
print(device.txPower); // int?, advertised dBm
});
await api.scan(const ScanOptions(
timeout: Duration(seconds: 12),
namePrefixes: ['Lab-'],
includeSpp: false,
));
ScanOptions.namePrefixes adds a per-scan filter. includeSpp enables parallel Android classic discovery and is ignored on iOS. Explicit stop and timeout both emit ScanState.stopped. Device IDs are opaque platform tokens and should only be passed back to this API.
Data transfer #
Scanning, connecting, disconnecting, and generic GATT transfer do not require configure(ProvisionProtocolProfile).
Raw GATT #
Use raw GATT when the application owns message boundaries or needs direct access to characteristic values. Check discovered properties before selecting an operation.
import 'dart:typed_data';
import 'package:ble_link_kit/ble_link_kit.dart';
final api = BleLinkKit();
await api.connect(deviceId);
final services = await api.discoverServices(deviceId);
for (final service in services) {
for (final characteristic in service.characteristics) {
final p = characteristic.properties;
print('${service.uuid}/${characteristic.uuid}: '
'read=${p.read}, write=${p.write}, '
'writeNoResponse=${p.writeNoResponse}, '
'notify=${p.notify}, indicate=${p.indicate}');
}
}
final value = await api.readCharacteristic(
deviceId,
serviceUuid,
characteristicUuid,
);
await api.writeCharacteristic(
deviceId,
serviceUuid,
characteristicUuid,
Uint8List.fromList([0x01, 0x02]),
withResponse: true,
);
final subscription = api.characteristicValueEvents.listen((event) {
if (event.deviceId == deviceId &&
event.serviceUuid == serviceUuid &&
event.characteristicUuid == characteristicUuid) {
print(event.value);
}
});
await api.setCharacteristicNotify(
deviceId,
serviceUuid,
characteristicUuid,
true,
);
final mtu = await api.negotiatedMtu(deviceId);
// Later:
await api.setCharacteristicNotify(
deviceId,
serviceUuid,
characteristicUuid,
false,
);
await subscription.cancel();
Operations use the native serialized GATT queue. While provisioning or OTA is active, raw access to the command/event characteristics owned by that operation fails with BleErrorCode.busy.
The plugin uses a single active connection. Every raw GATT call validates deviceId against that connection. Notification ownership is reference-counted across raw subscriptions, provisioning, and framed channels; the native subscription is disabled only after its final owner releases it.
Framed channels #
Use a framed channel when payloads can exceed one GATT write or when both endpoints need fragmentation, reassembly, a three-second assembly timeout, and per-fragment CRC16 validation. The peripheral must implement the frame format in doc/PROTOCOL.md.
final channelId = await api.openFramedChannel(
deviceId,
serviceUuid: serviceUuid, // Optional when characteristic UUIDs are unambiguous.
writeCharacteristicUuid: writeUuid,
notifyCharacteristicUuid: notifyUuid,
);
final frames = api.frameEvents.listen((message) {
if (message.channelId == channelId) {
print(message.payload); // Complete reassembled Uint8List.
}
});
await api.sendFrame(channelId, Uint8List.fromList([1, 2, 3, 4]));
// Later:
await api.closeFramedChannel(channelId);
await frames.cancel();
| Layer | Prefer it when | Application responsibility |
|---|---|---|
| Raw GATT | The device protocol already defines packet boundaries, or exact characteristic control is required | Encoding, message boundaries, retries, and validation |
| Framed channel | Arbitrary binary messages need transport fragmentation and integrity checks | Implementing the shared frame codec on the peripheral and defining payload semantics |
Framed channels provide transport integrity, not encryption, authentication, request matching, or OTA semantics. Channels are bound to the connection on which they were opened. After an unexpected disconnect, reopen each channel after reconnection; old channel IDs are invalid.
Power management #
PowerProfile supplies one policy for scanning, connection parameters, and PHY selection.
| Profile | Scanning | Connection parameters | PHY |
|---|---|---|---|
latencyFirst |
Android low latency; iOS continuous | Android HIGH during operations, BALANCED while idle; iOS firmware controlled | Android prefers LE 2M; iOS CoreBluetooth controlled |
balanced |
Android balanced; iOS 8000 ms active / 2000 ms idle | Android balanced; iOS firmware controlled | Android prefers LE 1M |
powerSaver |
Android low power; iOS 2000 ms active / 8000 ms idle | Android low power; iOS firmware controlled | Android prefers LE 1M |
await api.setPowerProfile(PowerProfile.balanced());
await api.scan(const ScanOptions(
powerModeOverride: BleScanPowerMode.lowPower,
));
await api.connect(
deviceId,
priorityOverride: BleConnectionPriority.high,
);
Without setPowerProfile, both platforms preserve 0.1.0 behavior. iOS priority overrides are advisory no-ops, exposes neither priority nor PHY selection, and does not emit ConnectionState.paramsUpdated. A custom scanDutyCycle controls iOS active/idle timing. Scan timeout is total wall-clock time, including idle windows. See firmware cooperation.
Provisioning & OTA #
Provisioning and OTA are optional higher-level features alongside generic transfer. They require one application-supplied ProvisionProtocolProfile; discovery and raw GATT do not.
await api.configure(const ProvisionProtocolProfile(
serviceUuid: '11111111-2222-3333-4444-555555555555',
commandUuid: '11111111-2222-3333-4444-666666666666',
eventUuid: '11111111-2222-3333-4444-777777777777',
deviceNamePrefixes: ['Demo-'],
operations: ProtocolOperations(
wifiConfig: 'demo_wifi_config',
wifiStatus: 'demo_wifi_status',
firmwareVersion: 'demo_firmware_version',
otaStart: 'demo_ota_start',
otaChunkAck: 'demo_ota_chunk_ack',
otaEnd: 'demo_ota_end',
otaStatus: 'demo_ota_status',
otaComplete: 'demo_ota_complete',
otaFailed: 'demo_ota_failed',
),
));
await api.provision(const WifiCredentials(
ssid: 'Network',
password: 'secret',
));
await api.startOta(
firmwareBytes,
targetVersion: '2.0.0',
);
The values are intentionally fictional. Provisioning and OTA share one exclusive operation lane. Concurrent calls fail with BleErrorCode.busy; calls before configure fail with BleErrorCode.configureRequired. OTA uses per-chunk acknowledgements, device-authoritative checkpoints, and final CRC32 verification. See doc/PROTOCOL.md for wire details and resume semantics.
Host integration #
iOS #
Add NSBluetoothAlwaysUsageDescription; add NSBluetoothPeripheralUsageDescription for older iOS versions. For background scanning, restoration, or OTA continuation, add bluetooth-central to UIBackgroundModes.
Keep restorationIdentifier non-empty, stable, and unique. The configured provisioning profile is persisted for cold-launch CoreBluetooth restoration; events are buffered until Flutter listens, up to the limit noted below.
Android #
The plugin manifest limits legacy Bluetooth and location permissions to API 30. Android 12+ uses BLUETOOTH_SCAN and BLUETOOTH_CONNECT; scanning is declared with neverForLocation, which may filter advertisements Android considers location-derived. Provisioning and OTA use a connectedDevice foreground service, so the host must allow affected foreground-service notifications.
Identity & binding #
Identity is an optional layer over the P2 secure channel. After opening that channel, applications can claim first ownership, present an owner token, issue an expiring restricted guest token, list owners, revoke a token, or reset device identity. Tokens are device-bound HMAC-SHA256 bearer credentials; certificate chains, asymmetric signatures, and attestation are future capabilities for devices with stronger secure hardware.
Owner records and local revocation state default to Android Keystore-backed
encrypted preferences and iOS Keychain
WhenUnlockedThisDeviceOnly storage. Neither backend exports the device owner
key. Tests may inject InMemorySecureTokenStore. Firmware remains authoritative
for MAC, kind, expiry, scope, and revocation checks.
Security #
Legacy provisioning, raw GATT writes, and CRC-framed channels do not provide end-to-end encryption or device authentication. P2 secure sessions provide authenticated encryption for secure provisioning, identity, and handoff operations. CRCs only detect accidental corruption. Production applications should require the symmetric tier for credentials, prevent replay, erase credentials promptly, and keep secrets out of logs. Native plugin logs redact SSID, password, key, token, MAC, and payload fields by default.
API snippet verification checklist #
Checked against the public exports and signatures in lib/ for 1.0.0,
including session negotiation, P2 secure channels, identity, signed
advertising, secure handoff, and the explicit security-policy APIs:
configureScanProfiles(List<ScanProfile>),ScanProfilefields,scan([ScanOptions]), and every accessedBleDeviceadvertisement field.connect(String, {priorityOverride}),discoverServices(String), allGattService/GattCharacteristic/GattPropertiesfields, andnegotiatedMtu(String).readCharacteristicandwriteCharacteristic(..., Uint8List, {withResponse})argument order and types.setCharacteristicNotify(..., bool),characteristicValueEvents, and every accessedCharacteristicValuefield.openFramedChannel(String, {writeCharacteristicUuid, notifyCharacteristicUuid, serviceUuid}),sendFrame,closeFramedChannel,frameEvents, andFramedMessagefields.setPowerProfile, scan/connection overrides,configure,provision, andstartOta(..., {required targetVersion, priorityOverride}).
Known issues (1.0.0) #
- Secure handoff descriptors and
toMap()contain plaintext next-hop credentials for application use. Keep them out of logs, persistence, analytics, and crash reports;toString()is redacted, but Dart heap zeroization is not guaranteed.
Native failures carry stable codes directly: firmware/version and OTA ACK/event waits use responseTimeout; a device-reported whole-image OTA CRC failure uses otaChecksumMismatch; Android SPP maps Bluetooth-off to bluetoothOff, unpaired/provision-response failures to protocolError, and socket connection exhaustion to connectTimeout.
BREAKING (0.4.0):
BleErrorCodegained the new valueresponseTimeout. Exhaustiveswitchstatements overBleErrorCodemust add a case for it (or adefaultbranch) before upgrading.
- On iOS,
connect()completes when GATT is ready or the attempt fails;connectionEventsreports the same terminal state. - iOS restoration discards a persisted profile with a mismatched schema; the app must call
configureagain. - iOS buffers at most 256 events before a Flutter listener attaches; older events are dropped.
- Android
paramsUpdateduses a runtime-dispatched hidden SDK callback and is best-effort telemetry. - Android CCCD behavior, write-without-response callback behavior, and iOS notification permission/background restoration for the new generic channels still require broader physical-device coverage.
- Frame channels are bound to the connection on which they were opened. After an unexpected disconnect, old
channelIdvalues are invalid and channels must be reopened after reconnection. - Android SPP in 0.4.0 is for provisioning only; it does not expose a generic byte stream.
- Secure-token mutations are serialized across plugin instances in one process. Independent application processes still rely on the operating-system store's atomic write behavior and should avoid concurrent logical updates.
License #
MIT. See LICENSE.