feat: implement BLE provisioning protocol, rudimentary UI

This commit is contained in:
2026-05-13 11:58:37 +02:00
parent c36168a8ef
commit 64f778fb6f
24 changed files with 3430 additions and 32 deletions
+221 -14
View File
@@ -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');
}
}
}