feat: implement BLE provisioning protocol, rudimentary UI
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
// Uses flutter_blue_plus to drive the ESP-IDF Unified Provisioning GATT protocol.
|
||||
// No BLE pairing or password required.
|
||||
//
|
||||
// Android: minSdkVersion must be ≥ 21 in android/app/build.gradle.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../../../generated/constants.pbenum.dart';
|
||||
import '../../../generated/network_config.pb.dart';
|
||||
import '../../../generated/sec0.pb.dart';
|
||||
import '../../../generated/session.pb.dart';
|
||||
|
||||
class BleScanResult {
|
||||
const BleScanResult({
|
||||
@@ -16,22 +22,223 @@ class BleScanResult {
|
||||
}
|
||||
|
||||
class BleProvisioner {
|
||||
// TODO: implement using flutter_blue_plus.
|
||||
// Filter scan by the ESP-IDF provisioning service UUID advertised by your firmware.
|
||||
// Default service UUID from ESP-IDF scheme_ble.c (bytes reversed from LSB-first array).
|
||||
// Verify this matches by scanning and logging serviceUuids on first test.
|
||||
static const _serviceUuid = '1775244d-6b43-439b-877c-060f2d9bed07';
|
||||
|
||||
/// Starts a BLE scan and emits discovered ESP32 provisioning devices.
|
||||
Stream<BleScanResult> scan() => throw UnimplementedError();
|
||||
// UUID 0x2901 — User Description GATT descriptor; maps endpoint names to chars.
|
||||
static const _userDescUuid = '2901';
|
||||
|
||||
Future<void> stopScan() async => throw UnimplementedError();
|
||||
BluetoothDevice? _connectedDevice;
|
||||
|
||||
Stream<BleScanResult> scan() async* {
|
||||
await _requestScanPermissions();
|
||||
|
||||
await FlutterBluePlus.startScan(
|
||||
withServices: [Guid(_serviceUuid)],
|
||||
timeout: const Duration(seconds: 30),
|
||||
);
|
||||
|
||||
await for (final results in FlutterBluePlus.scanResults) {
|
||||
for (final r in results) {
|
||||
final name = r.device.platformName;
|
||||
if (name.startsWith('anchor_')) {
|
||||
yield BleScanResult(
|
||||
deviceId: r.device.remoteId.str,
|
||||
name: name,
|
||||
rssi: r.rssi,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopScan() => FlutterBluePlus.stopScan();
|
||||
|
||||
/// Connects to [deviceId] and sends WiFi credentials via the ESP-IDF
|
||||
/// Unified Provisioning GATT profile (protobuf over BLE characteristic).
|
||||
Future<void> provision(
|
||||
String deviceId, {
|
||||
required String ssid,
|
||||
required String wifiPassword,
|
||||
}) async =>
|
||||
throw UnimplementedError();
|
||||
String? mqttHost,
|
||||
int? mqttPort,
|
||||
}) async {
|
||||
await _requestConnectPermissions();
|
||||
|
||||
void dispose() {}
|
||||
final device = BluetoothDevice.fromId(deviceId);
|
||||
_connectedDevice = device;
|
||||
|
||||
await device.connect(timeout: const Duration(seconds: 15));
|
||||
|
||||
try {
|
||||
await device.requestMtu(512);
|
||||
final services = await device.discoverServices();
|
||||
|
||||
final service = services.firstWhere(
|
||||
(s) => s.uuid == Guid(_serviceUuid),
|
||||
orElse: () => throw Exception('Provisioning service not found'),
|
||||
);
|
||||
|
||||
final charMap = await _buildCharMap(service);
|
||||
|
||||
await _probeVersion(charMap);
|
||||
await _initSession(charMap);
|
||||
await _setWifiConfig(charMap, ssid: ssid, password: wifiPassword);
|
||||
await _applyWifiConfig(charMap);
|
||||
|
||||
if (mqttHost != null) {
|
||||
await _sendMqttConfig(charMap, host: mqttHost, port: mqttPort ?? 1883);
|
||||
}
|
||||
} finally {
|
||||
await device.disconnect();
|
||||
_connectedDevice = null;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
FlutterBluePlus.stopScan();
|
||||
_connectedDevice?.disconnect();
|
||||
_connectedDevice = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Protocol steps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Reads the User Description (0x2901) descriptor on each characteristic to
|
||||
// build a map of endpoint name -> characteristic
|
||||
Future<Map<String, BluetoothCharacteristic>> _buildCharMap(
|
||||
BluetoothService service) async {
|
||||
final map = <String, BluetoothCharacteristic>{};
|
||||
for (final char in service.characteristics) {
|
||||
for (final desc in char.descriptors) {
|
||||
if (desc.uuid == Guid(_userDescUuid)) {
|
||||
try {
|
||||
final value = await desc.read();
|
||||
final name = utf8.decode(value, allowMalformed: true).trim();
|
||||
if (name.isNotEmpty) map[name] = char;
|
||||
} catch (_) {}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
Future<void> _probeVersion(
|
||||
Map<String, BluetoothCharacteristic> charMap) async {
|
||||
final char = _require(charMap, 'proto-ver');
|
||||
final resp = await _writeRead(char, utf8.encode('ESP'));
|
||||
try {
|
||||
final json = jsonDecode(utf8.decode(resp)) as Map<String, dynamic>;
|
||||
final prov = json['prov'] as Map<String, dynamic>?;
|
||||
if (prov != null) {
|
||||
// ignore: avoid_print
|
||||
print('[BleProvisioner] device ver=${prov['ver']} cap=${prov['cap']}');
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _initSession(
|
||||
Map<String, BluetoothCharacteristic> charMap) async {
|
||||
final char = _require(charMap, 'prov-session');
|
||||
final request = SessionData(
|
||||
secVer: SecSchemeVersion.SecScheme0,
|
||||
sec0: Sec0Payload(
|
||||
msg: Sec0MsgType.S0_Session_Command,
|
||||
sc: S0SessionCmd(),
|
||||
),
|
||||
);
|
||||
final respBytes = await _writeRead(char, request.writeToBuffer());
|
||||
final resp = SessionData.fromBuffer(respBytes);
|
||||
final status = resp.sec0.sr.status;
|
||||
if (status != Status.Success) {
|
||||
throw Exception('Session init failed: $status');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setWifiConfig(
|
||||
Map<String, BluetoothCharacteristic> charMap, {
|
||||
required String ssid,
|
||||
required String password,
|
||||
}) async {
|
||||
final char = _require(charMap, 'prov-config');
|
||||
final payload = NetworkConfigPayload(
|
||||
msg: NetworkConfigMsgType.TypeCmdSetWifiConfig,
|
||||
cmdSetWifiConfig: CmdSetWifiConfig(
|
||||
ssid: utf8.encode(ssid),
|
||||
passphrase: utf8.encode(password),
|
||||
),
|
||||
);
|
||||
final respBytes = await _writeRead(char, payload.writeToBuffer());
|
||||
final resp = NetworkConfigPayload.fromBuffer(respBytes);
|
||||
if (resp.respSetWifiConfig.status != Status.Success) {
|
||||
throw Exception(
|
||||
'Set WiFi config failed: ${resp.respSetWifiConfig.status}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyWifiConfig(
|
||||
Map<String, BluetoothCharacteristic> charMap) async {
|
||||
final char = _require(charMap, 'prov-config');
|
||||
final payload = NetworkConfigPayload(
|
||||
msg: NetworkConfigMsgType.TypeCmdApplyWifiConfig,
|
||||
cmdApplyWifiConfig: CmdApplyWifiConfig(),
|
||||
);
|
||||
final respBytes = await _writeRead(char, payload.writeToBuffer());
|
||||
final resp = NetworkConfigPayload.fromBuffer(respBytes);
|
||||
if (resp.respApplyWifiConfig.status != Status.Success) {
|
||||
throw Exception(
|
||||
'Apply WiFi config failed: ${resp.respApplyWifiConfig.status}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendMqttConfig(
|
||||
Map<String, BluetoothCharacteristic> charMap, {
|
||||
required String host,
|
||||
required int port,
|
||||
}) async {
|
||||
final char = _require(charMap, 'custom-mqtt-config');
|
||||
final json = '{"host":"$host","port":$port}';
|
||||
await _writeRead(char, utf8.encode(json));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<List<int>> _writeRead(
|
||||
BluetoothCharacteristic char, List<int> data) async {
|
||||
await char.write(data, withoutResponse: false);
|
||||
return char.read();
|
||||
}
|
||||
|
||||
BluetoothCharacteristic _require(
|
||||
Map<String, BluetoothCharacteristic> charMap, String name) {
|
||||
final char = charMap[name];
|
||||
if (char == null) throw Exception('BLE endpoint "$name" not found');
|
||||
return char;
|
||||
}
|
||||
|
||||
Future<void> _requestScanPermissions() async {
|
||||
final statuses = await [
|
||||
Permission.bluetoothScan,
|
||||
Permission.bluetoothConnect,
|
||||
Permission.locationWhenInUse,
|
||||
].request();
|
||||
|
||||
// Only BLE-specific permissions are required on API 31+ where BLUETOOTH_SCAN
|
||||
// is declared neverForLocation. On API 31+, ACCESS_FINE_LOCATION is absent from
|
||||
// the manifest and auto-denied without prompting, so location denial is not fatal.
|
||||
if ([Permission.bluetoothScan, Permission.bluetoothConnect]
|
||||
.any((p) => statuses[p]?.isDenied == true || statuses[p]?.isPermanentlyDenied == true)) {
|
||||
throw Exception('BLE permissions denied');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestConnectPermissions() async {
|
||||
final status = await Permission.bluetoothConnect.request();
|
||||
if (status.isDenied || status.isPermanentlyDenied) {
|
||||
throw Exception('Bluetooth connect permission denied');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user