ble_link_kit 1.0.0 copy "ble_link_kit: ^1.0.0" to clipboard
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.

English | 简体中文 | 日本語 | 한국어

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>), ScanProfile fields, scan([ScanOptions]), and every accessed BleDevice advertisement field.
  • connect(String, {priorityOverride}), discoverServices(String), all GattService / GattCharacteristic / GattProperties fields, and negotiatedMtu(String).
  • readCharacteristic and writeCharacteristic(..., Uint8List, {withResponse}) argument order and types.
  • setCharacteristicNotify(..., bool), characteristicValueEvents, and every accessed CharacteristicValue field.
  • openFramedChannel(String, {writeCharacteristicUuid, notifyCharacteristicUuid, serviceUuid}), sendFrame, closeFramedChannel, frameEvents, and FramedMessage fields.
  • setPowerProfile, scan/connection overrides, configure, provision, and startOta(..., {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): BleErrorCode gained the new value responseTimeout. Exhaustive switch statements over BleErrorCode must add a case for it (or a default branch) before upgrading.

  • On iOS, connect() completes when GATT is ready or the attempt fails; connectionEvents reports the same terminal state.
  • iOS restoration discards a persisted profile with a mismatched schema; the app must call configure again.
  • iOS buffers at most 256 events before a Flutter listener attaches; older events are dropped.
  • Android paramsUpdated uses 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 channelId values 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.

1
likes
130
points
62
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

Flutter BLE connection framework for multi-protocol scanning, generic GATT transfer, framed channels, provisioning, and resumable OTA.

Repository (GitHub)
View/report issues

Topics

#bluetooth #ble #gatt #scanner #ota

License

MIT (license)

Dependencies

crypto, cryptography, flutter, plugin_platform_interface

More

Packages that depend on ble_link_kit

Packages that implement ble_link_kit