ble_link_kit 0.2.0
ble_link_kit: ^0.2.0 copied to clipboard
Cross-platform Flutter plugin for parameterized BLE Wi-Fi provisioning, resilient framed transport, resumable OTA, and Android SPP fallback.
ble_link_kit #
A protocol-neutral Flutter plugin for resilient BLE Wi-Fi provisioning and resumable firmware updates on Android and iOS.
The plugin provides:
- Cross-platform BLE Wi-Fi provisioning with opaque device identifiers.
- A unified framed transport with fragmentation, reassembly, and CRC16 validation.
- Robust Android MTU negotiation, serialized GATT operations, and CCCD subscription.
- Exponential reconnect backoff and request/event matching by
reqId. - OTA with per-chunk acknowledgements, CRC32 verification, and checkpoint resume.
- CoreBluetooth state restoration on iOS.
- A connected-device foreground service on Android.
- RFCOMM Serial Port Profile fallback on Android only.
The plugin contains no product UUID, device-name prefix, or device-specific operation value. Your application must supply its complete protocol profile.
Platform support #
| Capability | Android | iOS |
|---|---|---|
| BLE scan, connect, provision | Yes | Yes |
| Framing, CRC16, event reassembly | Yes | Yes |
| Reconnect backoff | Yes | Yes |
| Resumable CRC32 OTA | Yes | Yes |
| Background connection support | Foreground service | CoreBluetooth restoration |
| RFCOMM SPP | Yes | No (unsupported) |
Check await api.capabilities before presenting platform-specific controls.
Power management #
PowerProfile supplies one policy for scanning, connection parameters, and PHY selection. The presets have these platform-specific effects:
| Profile | Scanning | Connection parameters | PHY |
|---|---|---|---|
latencyFirst |
Android low-latency scan; iOS continuous scan | Android requests HIGH during operations and restores BALANCED while idle; iOS relies on firmware | Android prefers LE 2M when supported; iOS relies on CoreBluetooth |
balanced |
Android balanced scan; iOS scans 8000 ms, then idles 2000 ms | Android requests balanced priority; iOS relies on firmware | Android prefers LE 1M; iOS relies on CoreBluetooth |
powerSaver |
Android low-power scan; iOS scans 2000 ms, then idles 8000 ms | Android requests low-power priority; iOS relies on firmware | Android prefers LE 1M; iOS relies on CoreBluetooth |
Set a global policy, then optionally override the scan mode or connection priority for one call:
If setPowerProfile is never called, both platforms preserve 0.1.0 legacy behavior: Android uses low-latency scanning, makes no connection-time priority or PHY request, temporarily requests HIGH during provisioning/OTA, and restores BALANCED afterward; iOS scans continuously. A per-call override is still treated as explicit intent in legacy mode.
latencyFirst is an explicit high-performance policy. Its Android HIGH priority is an operation-time strategy; after provisioning or OTA, the connection returns to BALANCED while idle.
await api.setPowerProfile(PowerProfile.balanced());
await api.scan(const ScanOptions(
powerModeOverride: BleScanPowerMode.lowPower,
));
await api.connect(
deviceId,
priorityOverride: BleConnectionPriority.high,
);
await api.startOta(
firmware,
targetVersion: '2.0.0',
priorityOverride: BleConnectionPriority.high,
);
For a custom profile, scanDutyCycle is the iOS active/idle schedule and takes precedence over the preset implied by its scanMode. A per-scan powerModeOverride instead uses that mode's standard iOS schedule; opportunistic uses the low-power 2000/8000 ms schedule because iOS has no system-level opportunistic scan mode. Scan timeout is always total wall-clock duration, including idle windows.
iOS exposes neither connection-priority requests nor PHY selection. Its priority overrides are accepted as advisory no-ops, and the peripheral firmware must initiate suitable Connection Parameter Update Requests. iOS also does not emit ConnectionState.paramsUpdated; gate platform-specific controls with capabilities. See Firmware cooperation for power profiles.
Quick start #
final api = BleLinkKit();
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.requestPermissions();
api.scanEvents.listen((event) async {
final device = event.device;
if (device == null) return;
await api.stopScan();
await api.connect(device.id);
await api.provision(const WifiCredentials(ssid: 'Network', password: 'secret'));
});
await api.scan();
The UUIDs and operation values above are intentionally fictional. See the runnable example for state-stream handling, SPP capability gating, and OTA progress.
Protocol profile #
ProvisionProtocolProfile #
| Field | Meaning |
|---|---|
serviceUuid |
Provisioning GATT service UUID. Required. |
commandUuid |
Writable command/data characteristic UUID. Required. |
eventUuid |
Notify or indicate event characteristic UUID. Required. |
deviceNamePrefixes |
Optional discovery allow-list; service UUID matching still applies. |
operations |
Complete wire-value mapping described below. |
notificationChannelId |
Android foreground-service notification channel identifier. |
notificationChannelName |
Android user-visible notification channel name. |
notificationTitle |
Android foreground notification title. |
restorationIdentifier |
iOS CoreBluetooth restoration identifier; keep stable between launches. |
checkpointKeyPrefix |
iOS UserDefaults prefix for local OTA progress hints. |
ProtocolOperations #
| Profile key | Role |
|---|---|
wifiConfig, wifiConnect, wifiStatus |
Send credentials, request association, and represent status. |
configReceived, wifiConnected, wifiFailed |
Provision acknowledgement and terminal events. |
firmwareVersion |
Firmware version request and response event. |
otaStart, otaStartAck |
Open an OTA session and report the device checkpoint. |
otaChunkAck |
Acknowledge a durably written chunk index. |
otaStatus |
Query the device checkpoint after reconnection. |
otaEnd, otaComplete, otaFailed |
Verify CRC32 and close the OTA session. |
Scan duration belongs to ScanOptions.timeout; ScanOptions.namePrefixes is unioned with the profile prefixes for that scan. includeSpp enables parallel Android classic discovery and is ignored on iOS. Explicit stop and timeout both emit ScanState.stopped. Connection, write, reassembly (3 seconds), acknowledgement, and retry timeouts are transport safety policies, not device protocol values. OTA target version and firmware bytes are supplied to startOta or resumeOta; chunk size is derived from the negotiated platform write limit.
Provisioning and OTA share one exclusive operation lane on both platforms: at most one of them may be active. While either is active, a new provisioning or OTA call is rejected immediately with BleLinkKitException(BleErrorCode.busy) and does not replace or cancel the active operation. Calls made before configure fail with BleErrorCode.configureRequired without occupying that lane. Cancellation, natural completion, and engine detach race atomically, so every accepted operation future is settled exactly once.
Host integration #
iOS #
The host Info.plist must contain:
NSBluetoothAlwaysUsageDescription.NSBluetoothPeripheralUsageDescriptionwhen supporting older iOS versions.UIBackgroundModescontainingbluetooth-centralwhen background scanning, restoration, or OTA continuation is required.
Keep a non-empty restorationIdentifier stable and unique to your app. On configure, iOS persists the complete profile under a plugin-prefixed UserDefaults key. On a later cold launch, plugin registration creates CoreBluetooth immediately from that stored profile, allowing restoration before Dart starts; restored device/state events are bounded and replayed after Flutter listens. A later configure with the same restoration identifier updates the existing client profile instead of replacing the restoration manager.
Android #
The plugin manifest declares legacy BLUETOOTH, BLUETOOTH_ADMIN, and location permissions only through API 30. Android 12 and later use BLUETOOTH_SCAN and BLUETOOTH_CONNECT; scan is declared with neverForLocation.
neverForLocation minimizes permission scope but Android may filter advertisements it considers location-derived. Remove that flag in a fork only when your discovery requirements justify location access and your privacy disclosure is updated. Provisioning and OTA use a connectedDevice foreground service; the host must allow foreground-service notifications on affected Android versions.
Security #
This package does not provide end-to-end credential encryption. WifiCredentials.password enters the BLE/SPP protocol payload as plaintext before link-layer transport; BLE pairing or encryption alone is not a substitute for authenticating the target device.
The credential-encoder hook point is immediately before the native clients serialize the Wi-Fi configuration object and pass it to the frame encoder. The public 0.1.0 API does not ship a cryptographic encoder implementation: applications requiring one must extend that boundary with their device's authenticated key agreement and ciphertext envelope on both phone and firmware. Never invent a static shared key inside the plugin.
Native logs are injectable and redact SSID, PSK/password, and frame payload fields by default. Production deployments should additionally authenticate devices, establish per-session keys, protect against replay, erase credentials promptly, and avoid exposing secrets through application-level logging or crash reports.
OTA and resume semantics #
OTA begins with a version query and start acknowledgement, transfers raw framed chunks with a window size of one, waits for each durable chunk acknowledgement, then requests final CRC32 verification. On interruption, the client reconnects and queries otaStatus.
The device checkpoint is authoritative. A phone-side checkpoint is only a diagnostic/performance hint and must never cause the client to skip data beyond the maximum contiguous index reported by the device. Progress reaches 100% only after the completion event.
Protocol reference #
See doc/PROTOCOL.md for frame layout, provisioning flow, OTA flow, checksums, and reqId matching semantics.
Known issues (0.2.0) #
- Some low-level failures still surface as the generic
protocolErrorcode; the structured error channel exists, but OTA, version-query, and SPP failure sites are not yet fully classified into finer codes such asotaChecksumMismatchordisconnected. - On iOS, the
connect()future completes when the connection request is accepted, not when GATT is ready. Always observeconnectionEventsforconnected/failedterminal states. - iOS restoration discards a persisted profile whose schema does not match (no automatic migration); the app must call
configureagain. - iOS buffers at most 256 events while no Flutter listener is attached; older events are dropped beyond that limit.
- An iOS scan resumed through CoreBluetooth state restoration re-arms a fresh 16-second wall-clock timeout instead of continuing the caller's original timeout.
- The active
PowerProfileis not persisted across iOS process restarts; a restored scan uses the legacy continuous schedule untilsetPowerProfileis called again. - Android
paramsUpdatedevents rely on a runtime-dispatched hidden SDK callback and are best-effort telemetry, not a delivery guarantee.
License #
MIT. See LICENSE.