flutter_elink_ble
Flutter plugin for the ElinkThings BLE SDK. The Dart API exposes Bluetooth adapter state, scanning, connection lifecycle, protocol data callbacks, characteristic events, Elink protocol helpers, and Dart-side WiFi provisioning helpers.
Native code delegates scan, connect, disconnect, and write operations to the official Elink SDK:
- Android:
AILinkBleManagerfromAILinkSDKRepositoryAndroid - iOS:
ELAILinkBleManagerfromAILinkBleSDK.framework
WiFi provisioning commands are built in Dart and sent through the shared writeA6 path, so Android and iOS use the same command payloads and response parsing.
Features
- Listen to Bluetooth state with
ElinkBle.bluetoothStateorElinkBle.setBluetoothStateCallback. - Scan Elink broadcast and connectable devices.
- Parse
CID,VID,PID, andMACfrom Elink manufacturer data. - Connect multiple BLE devices, disconnect by
remoteId, and write BLE data through the native SDK. - Send A6 and A7 payloads with
ElinkBle.writeA6()andElinkBle.writeA7(). - Receive SDK A6/A7 payload callbacks through
ElinkBle.protocolDataPackets. - Receive passthrough or non-protocol data through
ElinkBle.passthroughDataPackets. - Receive low-level characteristic events through
ElinkBle.characteristicEvents. - Request Android MTU changes and read iOS maximum write lengths.
- Configure Android SDK command resend count from Flutter. It is disabled by
default and enabled when
resendCount >= 1. - Run WiFi provisioning commands from Dart and listen for typed WiFi events.
- Use native SDK helpers for broadcast decrypt, MCU A7 encrypt/decrypt, and handshake; Flutter only bridges the native handshake result.
Installation
Install with the Flutter command:
flutter pub add flutter_elink_ble
Or add it manually to pubspec.yaml:
dependencies:
flutter_elink_ble: ^0.2.4
Android Setup
The Android implementation initializes the Elink native SDK and bridges SDK callbacks to Dart. It does not implement its own BLE scanning or connection logic.
If the host project does not already include JitPack, add it:
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
The plugin manifest declares BLE permissions, but the host app must still request runtime permissions for the current Android version:
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
Android 12 and later must request BLUETOOTH_SCAN, BLUETOOTH_ADVERTISE, and BLUETOOTH_CONNECT; the ordinary AILink SDK scan entry checks the complete Nearby devices permission set. Android 11 and earlier usually require location permission for BLE scanning.
iOS Setup
Add Bluetooth usage descriptions to the host app Info.plist:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Need BLE permission to scan and connect Elink devices.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>Need BLE permission to scan and connect Elink devices.</string>
The plugin currently vendors AILinkBleSDK.framework. The sample framework contains an arm64 device slice, so it is suitable for real device builds. For simulator builds, replace it with an AILinkBleSDK.xcframework that includes simulator slices.
AILinkBleSDK.framework is a static archive and contains Objective-C categories such as ELAILinkBleManager+WIFI. The plugin podspec injects -ObjC into the Pod target so those category methods are linked into flutter_elink_ble.framework. Run pod install again after updating the plugin; otherwise runtime may fail with unrecognized selector.
Each iOS connection is handled by its own ELAILinkBleManager session so the SDK's current peripheral state is not shared across connected devices. When connecting from a recent scan result, the plugin first tries to retrieve the target CBPeripheral by identifier through that session's CBCentralManager; if it cannot be retrieved, it falls back to a session-local scan for the same remoteId.
iOS operations that require a ready connection, such as RSSI reads, writes, A6/A7 commands, BM version queries, and MTU reads, return a device_not_connected platform error when the target remoteId is not connected. Old disconnect callbacks are scoped to the session that created them, so reconnecting the same remoteId is not invalidated by stale callbacks.
If Bluetooth is on but scanning fails on iOS, check the host app permission text and the app's Bluetooth permission in iOS Settings. Native errors are normalized as bluetooth_off, bluetooth_unauthorized, bluetooth_unsupported, or bluetooth_not_ready.
Quick Start
import 'package:flutter_elink_ble/flutter_elink_ble.dart';
final supported = await ElinkBle.isSupported;
await ElinkBle.openBluetooth();
await ElinkBle.refreshAdapterState();
final stateSub = ElinkBle.bluetoothState.listen((state) {
print('Bluetooth state: ${state.name}');
});
ElinkBle.setBluetoothStateCallback((state) {
print('Bluetooth callback: ${state.name}');
});
final scanSub = ElinkBle.scanResults.listen((results) {
for (final result in results) {
print(
'${result.device.remoteId} '
'${result.device.macAddress} '
'CID=${result.advertisementData.identity.cidValue}',
);
}
});
await ElinkBle.startScan(
timeout: const Duration(seconds: 10),
androidScanMode: ElinkAndroidScanMode.lowLatency, // Android only.
);
Example BLE Flow
The example app follows this order for scanning, connecting, native handshake, BM version, and MTU handling:
flowchart TD
A[Open Bluetooth / refreshAdapterState] --> B[startScan]
B --> C[scanResults]
C --> D[connect selected device]
D --> E[open device tab]
E --> F[connectionEvents: connected]
F --> G[serviceDiscoveryEvents]
G --> H[native SDK handshake]
H --> I[handshakeEvents: success]
I --> J[getBmVersion]
J --> K[native get BM version 0x46]
K --> L[bmVersionEvents]
I --> M{Platform MTU action}
M -- Android --> N[setAndroidMtu 517]
N --> O[mtuEvents]
M -- iOS --> P[getIosMtu]
P --> Q[withoutResponse / withResponse lengths]
In the example implementation:
- Scan uses
ElinkBle.startScan()andElinkBle.scanResults. - Connection readiness is tracked by
ElinkBle.connectionEventsandElinkBle.serviceDiscoveryEvents. ElinkBle.connect()does not stop active scanning in the plugin. The example stops its current scan before connecting the selected device, then creates one tab per connected device and automatically switches to the new tab.- Device operations are routed by
remoteId, so writes, MTU, RSSI, WiFi provisioning, and disconnect actions target the current device tab. - Handshake is handled automatically by the native Android/iOS SDKs; results are
emitted through
ElinkBle.handshakeEvents. - BM version is queried with
ElinkBle.getBmVersion(), which calls the native SDK enhanced0x46query (getBleVersion46on Android andGetBMVersionProon iOS). The Android native SDK only auto-reads the legacy0x0Eversion, so the enhanced0x46query remains an explicit Flutter API. Native legacy0x0Ereports are still bridged throughbmVersionEventswhen the native SDK emits them. - Android uses
ElinkBle.setAndroidMtu(remoteId, 517)and listens toElinkBle.mtuEvents. - iOS uses
ElinkBle.getIosMtu(remoteId)and reads the negotiated.withoutResponseand.withResponsemaximum write lengths.
BM version query:
// Enhanced BM version command: native SDK query 0x46.
await ElinkBle.getBmVersion(result.device.remoteId);
final bmVersionSub = ElinkBle.bmVersionEvents.listen((event) {
print(
'${event.remoteId}: '
'command=0x${event.command.toRadixString(16).padLeft(2, '0').toUpperCase()} '
'type=${event.versionKind} '
'version=${event.version}',
);
});
Connect and write:
await ElinkBle.stopScan(); // Example UI stops the current scan before connecting.
await ElinkBle.connect(result.device);
await ElinkBle.writeA6(result.device.remoteId, [0x01, 0x02]);
await ElinkBle.writeA7(result.device.remoteId, [0x03, 0x04], cid: 0x1234);
await ElinkBle.write(
result.device.remoteId,
ElinkDataProcessor.wrapA6Frame([0x01, 0x02]),
);
await ElinkBle.readRssi(result.device.remoteId);
// Android only: request GATT MTU. Result is emitted by ElinkBle.mtuEvents.
await ElinkBle.setAndroidMtu(result.device.remoteId, 247);
// Android only: command resend is disabled by default; 0 disables it again.
await ElinkBle.setAndroidCommandResendCount(resendCount: 3);
await ElinkBle.setAndroidCommandResendCount();
// iOS only: read CoreBluetooth maximum write lengths for the target connection.
final iosMtu = await ElinkBle.getIosMtu(result.device.remoteId);
print(
'iOS write lengths: '
'withoutResponse=${iosMtu.maxWriteWithoutResponse} '
'withResponse=${iosMtu.maxWriteWithResponse}',
);
// Android only: set preferred PHY.
await ElinkBle.setAndroidPreferredPhy(
result.device.remoteId,
txPhy: ElinkAndroidPhy.phy2M,
rxPhy: ElinkAndroidPhy.phy2M,
);
await ElinkBle.disconnect(result.device.remoteId);
WiFi Provisioning
WiFi APIs are implemented in Dart. The plugin builds A6 payloads using one shared command builder and sends them through the native SDK writeA6 bridge. Command logs are disabled by default and can be enabled with ElinkBle.wifiCommandLoggingEnabled.
For devices that must register with the server before provisioning is considered successful, use wifiConfigureServerAndConnect; it writes server host, port, and path before sending the WiFi MAC, password, and connect command.
final wifiEventsSub = ElinkBle.wifiEvents.listen((event) {
print('${event.type} ${event.remoteId} ${event.value}');
});
final wifiScanSub = ElinkBle.wifiScanResults.listen((accessPoints) {
for (final accessPoint in accessPoints) {
print('${accessPoint.ssid} ${accessPoint.macAddress} ${accessPoint.rssi}');
}
});
final wifiStatusSub = ElinkBle.wifiStatusEvents.listen((status) {
print(
'ble=${status.bleStatus.name} '
'wifi=${status.wifiStatus.name} '
'work=${status.workStatus.name} '
"fail=${status.failStatus?.name ?? '-'}",
);
});
final wifiResponseSub = ElinkBle.wifiResponseEvents.listen((response) {
print('command=${response.command} status=${response.status.name}');
});
ElinkBle.wifiCommandLoggingEnabled = true;
await ElinkBle.wifiGetCurrentState(result.device.remoteId);
await ElinkBle.wifiScan(result.device.remoteId);
await ElinkBle.wifiConfigureServerAndConnect(
result.device.remoteId,
host: 'ailink.iot.aicare.net.cn',
port: 80,
path: '',
macAddress: 'AA:BB:CC:DD:EE:FF',
password: '12345678',
);
await ElinkBle.wifiGetConnectedSsid(result.device.remoteId);
await ElinkBle.wifiGetConnectedMac(result.device.remoteId);
await ElinkBle.wifiGetConnectedPassword(result.device.remoteId);
await ElinkBle.wifiGetDeviceSn(result.device.remoteId);
await ElinkBle.wifiGetServerInfo(result.device.remoteId);
Release WiFi subscriptions when done:
await wifiEventsSub.cancel();
await wifiScanSub.cancel();
await wifiStatusSub.cancel();
await wifiResponseSub.cancel();
ElinkBle.wifiCommandLoggingEnabled = false;
Protocol Helpers
Generic A6/A7 frame parsing and A7/TLV packet building:
final commonFrame = ElinkDataProcessor.parseProtocolFrame(
ElinkDataProcessor.wrapA6Frame([0x46]),
);
print('${commonFrame.protocol.name} ${commonFrame.payload}');
// Full A7 frame: A7 + CID(2) + payloadLength + TLV payload + checksum + 7A.
final frame = ElinkDataProcessor.parseA7Frame([
0xA7, 0x00, 0x8F, 0x08,
0x01, 0x06, 0x67, 0xA7, 0x1F, 0x0E, 0x01, 0x08,
0xE2, 0x7A,
]);
final tlvs = ElinkDataProcessor.parseTlvPayload(frame.payload);
final timestamp = tlvs.first.readInt(length: 4); // Big-endian by default.
final request = ElinkDataProcessor.wrapA7TlvFrame(
cid: 0x008F,
tlvs: [
ElinkPayload(type: 0x02), // L=0, no V.
ElinkPayload(type: 0x03, data: [0x01, 0x01]),
],
);
await ElinkBle.write(result.device.remoteId, request);
final payloadChunks = ElinkDataProcessor.buildTlvPayloadChunks(
[
ElinkPayload(type: 0x02),
ElinkPayload(type: 0x03, data: [0x01, 0x01]),
],
maxPayloadLength: 20,
);
for (final payload in payloadChunks) {
final chunkRequest = ElinkDataProcessor.wrapA7Frame(
cid: 0x008F,
payload: payload,
);
await ElinkBle.write(result.device.remoteId, chunkRequest);
}
print(ElinkDataProcessor.formatHex(request)); // A7 00 8F ...
Listen for protocol, passthrough, and characteristic callbacks:
final protocolSub = ElinkBle.protocolDataPackets.listen((packet) {
print('${packet.protocol.name} ${packet.deviceType} ${packet.data}');
});
final passthroughSub = ElinkBle.passthroughDataPackets.listen((packet) {
print(packet.data);
});
final characteristicSub = ElinkBle.characteristicEvents.listen((event) {
print('${event.operation.name} ${event.characteristicUuid}');
});
final rssiSub = ElinkBle.rssiEvents.listen((event) {
print('${event.remoteId} ${event.rssi}');
});
final mtuSub = ElinkBle.mtuEvents.listen((event) {
print('${event.remoteId} ${event.mtu} ${event.availableMtu}');
});
Release resources when done:
await stateSub.cancel();
await scanSub.cancel();
await protocolSub.cancel();
await passthroughSub.cancel();
await characteristicSub.cancel();
await rssiSub.cancel();
await mtuSub.cancel();
await ElinkBle.disconnect(result.device.remoteId);
await ElinkBle.dispose();
Default Elink UUIDs
| Purpose | UUID |
|---|---|
| Broadcast device service | F0A0 |
| Connectable device service | FFE0 |
| Write characteristic | FFE1 |
| Notify characteristic | FFE2 |
| Write + notify characteristic | FFE3 |
Event Contract
Native events are normalized into these Dart streams:
| Native type | Dart API | Description |
|---|---|---|
adapterState |
ElinkBle.adapterState, ElinkBle.bluetoothState |
Bluetooth state |
scanResult |
ElinkBle.scanResults |
Scan results deduplicated by remoteId |
scanStopped |
ElinkBle.isScanning |
Scan stopped or timed out |
connectionState |
ElinkBle.connectionEvents |
GATT connection state |
servicesDiscovered |
ElinkBle.serviceDiscoveryEvents |
Service and characteristic discovery result |
protocolData |
ElinkBle.protocolDataPackets, ElinkBle.wifiEvents |
SDK A6/A7 payload callback; WiFi A6 payloads are also parsed by Dart |
passthroughData |
ElinkBle.passthroughDataPackets |
SDK passthrough or non-protocol data |
characteristicEvent |
ElinkBle.characteristicEvents |
Low-level read, write, descriptor write, changed, or notification-state event |
rssi |
ElinkBle.rssiEvents |
Connected-device RSSI read result |
mtu |
ElinkBle.mtuEvents |
Android MTU change result |
handshake |
ElinkBle.handshakeEvents |
Native SDK handshake result |
bmVersion |
ElinkBle.bmVersionEvents |
Native SDK BM version result |
error |
ElinkBle.errors |
Plugin error |
WiFi-specific Dart streams:
| Dart API | Description |
|---|---|
ElinkBle.wifiEvents |
Generic WiFi events, including optional command logs |
ElinkBle.wifiScanResults |
WiFi access points deduplicated by MAC or scan id |
ElinkBle.wifiStatusEvents |
BLE, WiFi, and module work status |
ElinkBle.wifiResponseEvents |
WiFi command response events |
Notes
- Check
ElinkBle.bluetoothStateNow == ElinkAdapterState.onbefore scanning. - Use
ElinkBle.openBluetooth()to guide the user to enable Bluetooth. Android opens the system enable prompt or Bluetooth settings; iOS cannot turn Bluetooth on directly, so the plugin only refreshes current state and does not open Settings. Listen toElinkBle.bluetoothStatefor the final state. - Android 7.0+ throttles BLE scanning; avoid more than 5
startScancalls in 30 seconds. The plugin reuses an active scan with the same configuration and blocks too-fast Android restarts withscan_throttledandretryAfterMs. - iOS
remoteIdisCBPeripheral.identifier, not a MAC address. - iOS multi-device connections use one
ELAILinkBleManagersession perremoteId; always pass the targetremoteIdwhen writing or disconnecting. - iOS connection-scoped operations return
device_not_connectedwhen the target session is not ready; handle this like any other platform exception. - iOS does not support active MTU requests from apps; use the system-negotiated maximum write length instead.
- Android command resend is disabled by default. Set
resendCount >= 1only when the business flow needs SDK-level retry after a failed command send. - Android 12+ host apps must request
BLUETOOTH_SCAN,BLUETOOTH_ADVERTISE, andBLUETOOTH_CONNECTruntime permissions themselves. Android 11 and earlier also require location permission for scanning, and system location services must be enabled. - A6/A7 writes should use
writeA6andwriteA7; the native SDK adds frame headers, tails, and checksums. - Raw
writeremains available only for business code that already builds full packets. - WiFi command logs are disabled by default. Enable them only when debugging command payloads.