Files
companion/lib/data/sources/ble/ble_provisioner.dart
2026-05-16 12:00:55 +02:00

280 lines
8.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;
// Continuously scans for nearby ESP32 sensors, restarting after each
// 15-second window, until the returned stream is cancelled.
Stream<BleScanResult> scan() {
StreamSubscription<List<ScanResult>>? resultsSub;
StreamSubscription<bool>? stateSub;
bool started = false;
late StreamController<BleScanResult> controller;
Future<void> startScan() async {
if (controller.isClosed) return;
started = true;
try {
await FlutterBluePlus.startScan(
withServices: [Guid(_serviceUuid)],
);
} catch (_) {}
}
controller = StreamController<BleScanResult>(
onListen: () async {
try {
await _requestScanPermissions();
} catch (e) {
controller.addError(e);
await controller.close();
return;
}
resultsSub = FlutterBluePlus.scanResults.listen((results) {
for (final r in results) {
final name = r.device.platformName;
if (name.startsWith('anchor_') && !controller.isClosed) {
controller.add(BleScanResult(
deviceId: r.device.remoteId.str,
name: name,
rssi: r.rssi,
));
}
}
});
if(!FlutterBluePlus.isScanningNow) {
await startScan();
}
},
onCancel: () {
resultsSub?.cancel();
stateSub?.cancel();
FlutterBluePlus.stopScan();
controller.close();
},
);
return controller.stream;
}
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');
}
}
}