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 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 stopScan() => FlutterBluePlus.stopScan(); Future 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> _buildCharMap( BluetoothService service) async { final map = {}; 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 _probeVersion( Map 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; final prov = json['prov'] as Map?; if (prov != null) { // ignore: avoid_print print('[BleProvisioner] device ver=${prov['ver']} cap=${prov['cap']}'); } } catch (_) {} } Future _initSession( Map 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 _setWifiConfig( Map 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 _applyWifiConfig( Map 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 _sendMqttConfig( Map 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> _writeRead( BluetoothCharacteristic char, List data) async { await char.write(data, withoutResponse: false); return char.read(); } BluetoothCharacteristic _require( Map charMap, String name) { final char = charMap[name]; if (char == null) throw Exception('BLE endpoint "$name" not found'); return char; } Future _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 _requestConnectPermissions() async { final status = await Permission.bluetoothConnect.request(); if (status.isDenied || status.isPermanentlyDenied) { throw Exception('Bluetooth connect permission denied'); } } }