245 lines
7.7 KiB
Dart
245 lines
7.7 KiB
Dart
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({
|
|
required this.deviceId,
|
|
required this.name,
|
|
required this.rssi,
|
|
});
|
|
|
|
final String deviceId;
|
|
final String name;
|
|
final int rssi;
|
|
}
|
|
|
|
class BleProvisioner {
|
|
// 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';
|
|
|
|
// UUID 0x2901 — User Description GATT descriptor; maps endpoint names to chars.
|
|
static const _userDescUuid = '2901';
|
|
|
|
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();
|
|
|
|
Future<void> provision(
|
|
String deviceId, {
|
|
required String ssid,
|
|
required String wifiPassword,
|
|
String? mqttHost,
|
|
int? mqttPort,
|
|
}) async {
|
|
await _requestConnectPermissions();
|
|
|
|
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');
|
|
}
|
|
}
|
|
}
|