/* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD SPDX-License-Identifier: MIT */ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:stack_chan/app_state.dart'; import 'package:stack_chan/model/expression_data.dart'; import 'package:stack_chan/util/value_constant.dart'; import 'package:stack_chan/view/app.dart'; import 'package:stack_chan/view/popup/device_wifi_config.dart'; import '../model/blue_device_info.dart'; import '../model/dance_list.dart'; class BlueUtil { static final BlueUtil shared = BlueUtil._internal(); BlueUtil._internal() { _initialize(); } static const String danceTargetServiceUUID = "e2e5e5e0-1234-5678-1234-56789abcdef0"; //MARK: - Core constants (align with iOS) static const String targetServiceUUID = "e2e5e5ff-1234-5678-1234-56789abcdef0"; static const String headCharacteristicUUID = "0000ffe1-0000-1000-8000-00805f9b34fb"; static const String wifiSetCharacteristicUUID = "e2e5e5e3-1234-5678-1234-56789abcdef0"; static const String expressionCharacteristicUUID = "0000ffe3-0000-1000-8000-00805f9b34fb"; static const String writeCharacteristicUUID = "0000ffe4-0000-1000-8000-00805f9b34fb"; //MARK: - Core properties (align with iOS) List discoveredDevices = []; bool blueSwitch = false; final bool autoReconnect = true; BluetoothDevice? currentPeripheral; //Auto scan enabled by default bool automaticScanning = true; //Feature object BluetoothCharacteristic? writeCharacteristic; BluetoothCharacteristic? writeExpressionCharacteristic; BluetoothCharacteristic? writeHeadCharacteristic; BluetoothCharacteristic? writeWifiSetCharacteristic; //MARK: - Callback closures Function(List)? blufDevicesMonitoring; Function(BluetoothAdapterState)? centralManagerDidUpdateState; Function(BluetoothDevice, BluetoothCharacteristic)? characteristicCallback; Function(BluetoothDevice, bool)? connectionStateChanged; Function(List)? wifiSetCharacteristicCall; //MARK: - Private properties StreamSubscription? _scanSubscription; StreamSubscription? _adapterStateSubscription; StreamSubscription? _connectionStateSubscription; Timer? _cleanupTimer; final Duration _deviceTimeout = const Duration(seconds: 3); static const String motionCharacteristicUUID = "e2e5e5e1-1234-5678-1234-56789abcdef0"; static const String avatarCharacteristicUUID = "e2e5e5e2-1234-5678-1234-56789abcdef0"; static const String configCharacteristicUUID = "e2e5e5e3-1234-5678-1234-56789abcdef0"; static const String rgbCharacteristicUUID = "e2e5e5e4-1234-5678-1234-56789abcdef0"; BluetoothCharacteristic? writeMotionCharacteristic; BluetoothCharacteristic? writeAvatarCharacteristic; BluetoothCharacteristic? writeRGBCharacteristic; int blueMode = 1; //1 WiFi mode 2 Dance mode 3 Pairing mode ///Cache scanned device list for change comparison List cachedDeviceMacs = []; //MARK: - Initialization (fix Android first timing issue: permission → listen → enable Bluetooth) void _initialize() { //[Fix] Request permission first, init listener and Bluetooth after permission granted _requestBluetoothPermissions(); } //MARK: - Request Bluetooth permission (permission_handler) Future _requestBluetoothPermissions() async { try { Map statuses; if (Platform.isAndroid) { //Android 12+ permissions statuses = await [ Permission.bluetooth, Permission.bluetoothScan, Permission.bluetoothConnect, Permission.location, ].request(); } else if (Platform.isIOS) { //iOS permissions statuses = await [ Permission.bluetooth, Permission.bluetoothScan, Permission.bluetoothConnect, Permission.location, ].request(); } else { return; } bool allGranted = true; statuses.forEach((permission, status) { if (!status.isGranted) { allGranted = false; } }); //Execute enable Bluetooth command regardless of success to fix some plugin bugs if (allGranted) { _registerBluetoothStateListener(); await _tryTurnOnBluetooth(); } else { _registerBluetoothStateListener(); await _tryTurnOnBluetooth(); } } catch (e) { _registerBluetoothStateListener(); await _tryTurnOnBluetooth(); } } void _registerBluetoothStateListener() { _adapterStateSubscription?.cancel(); _adapterStateSubscription = FlutterBluePlus.adapterState.listen((state) { centralManagerDidUpdateState?.call(state); _centralManagerDidUpdateState(state); }); } //MARK: - [New] Auto enable Bluetooth Future _tryTurnOnBluetooth() async { try { final currentState = FlutterBluePlus.adapterStateNow; if (currentState == BluetoothAdapterState.off) { await FlutterBluePlus.turnOn(); } else if (currentState == BluetoothAdapterState.on) { //[fixkey]Androidfirstpermissionsuccess+BluetoothAlreadyenable → proactivetriggerscan blueSwitch = true; if (automaticScanning) { startScan(); } if (autoReconnect) { reconnect(); } } } catch (e) { } } //MARK: - Bluetooth status update (auto scan, auto reconnect) void _centralManagerDidUpdateState(BluetoothAdapterState state) { switch (state) { case BluetoothAdapterState.unknown: break; case BluetoothAdapterState.unavailable: break; case BluetoothAdapterState.unauthorized: break; case BluetoothAdapterState.turningOn: break; case BluetoothAdapterState.on: blueSwitch = true; //Bluetoothenable autostartscan if (automaticScanning) { startScan(); } //autoreconnect if (autoReconnect) { reconnect(); } break; case BluetoothAdapterState.turningOff: break; case BluetoothAdapterState.off: blueSwitch = false; //closeafterautoTryreOpen _tryTurnOnBluetooth(); break; } } //MARK: - Scan related (auto execute) void startScan() { if (FlutterBluePlus.adapterStateNow != BluetoothAdapterState.on) { //stateNotMeet / Satisfy,autoOpenBluetooth _tryTurnOnBluetooth(); return; } discoveredDevices.clear(); FlutterBluePlus.startScan( withServices: [Guid(targetServiceUUID), Guid(danceTargetServiceUUID)], continuousUpdates: true, removeIfGone: _deviceTimeout, ); _scanSubscription = FlutterBluePlus.scanResults.listen( (results) { for (var result in results) { _centralManagerDidDiscoverPeripheral(result); } }, onError: (e) { }, ); _startCleanupTimer(); } //Corresponds to iOS centralManager didDiscover peripheral method void _centralManagerDidDiscoverPeripheral(ScanResult result) { final advertisementDataMap = { ValueConstant.advName: result.advertisementData.advName, ValueConstant.txPowerLevel: result.advertisementData.txPowerLevel, ValueConstant.connectable: result.advertisementData.connectable, ValueConstant.serviceUuids: result.advertisementData.serviceUuids .map((g) => g.toString()) .toList(), ValueConstant.serviceData: result.advertisementData.serviceData.map( (k, v) => MapEntry(k.toString(), v), ), ValueConstant.manufacturerData: result.advertisementData.manufacturerData, }; final deviceInfo = BlueDeviceInfo( device: result.device, advertisementData: advertisementDataMap, rssi: result.rssi, lastSeen: DateTime.now(), ); final index = discoveredDevices.indexWhere( (d) => d.device.remoteId == result.device.remoteId, ); if (index == -1) { discoveredDevices.add(deviceInfo); } else { discoveredDevices[index] = deviceInfo; } //[Fix]WhenbounddeviceCurrentlyinconnectorconnectedwhen,NotexecutedevicediscoverStreamProcess / Thread if (currentPeripheral != null) { return; } //Determine behavior based on blueMode switch (blueMode) { case 1: //Change WiFi mode: Check device changes, auto connect bound devices _checkDeviceChanges(); break; case 2: //Dance mode: Only connect own device (requires deviceControlMode == 1) if (AppState.shared.deviceControlMode == 1) { screenMyDevice(discoveredDevices); } break; case 3: //pairingmode:callbackdevicelistFor / ToUIshow blufDevicesMonitoring?.call(discoveredDevices); break; } } ///checkdevicelistchange,hasnewdevicewhencheckwhetherbound Future _checkDeviceChanges() async { final newDate = DateTime.now(); if (AppState.shared.manualShutdownTime != null) { final Duration difference = newDate.difference( AppState.shared.manualShutdownTime!, ); if (difference.inSeconds < 6) { return; } } //Get MAC addresses of all current devices List currentMacs = []; for (final device in discoveredDevices) { final mac = _getDeviceId(device); if (mac != null) { currentMacs.add(mac.toUpperCase()); } } //comparewhetherhasnewdeviceAppear / Occur bool hasNewDevice = false; for (final mac in currentMacs) { if (!cachedDeviceMacs.contains(mac)) { hasNewDevice = true; break; } } //updatecache cachedDeviceMacs = currentMacs; //ifhasnewdevice,AnduserAlreadylogin,checkwhetherisbounddevice if (hasNewDevice && AppState.shared.isLogin.value) { //Getbounddevicelist await AppState.shared.getDevices(); //checkcurrentscantodevicewhetherinboundlistin for (final deviceInfo in discoveredDevices) { final String? deviceMac = _getDeviceId(deviceInfo); if (deviceMac == null) continue; final upperMac = deviceMac.toUpperCase(); //checkwhetherisbounddevice final isBound = AppState.shared.devices.any( (device) => device.mac.toUpperCase() == upperMac, ); if (isBound && currentPeripheral == null) { currentPeripheral = deviceInfo.device; //[Fix]First / Previouslymarkconnectin,connectsuccessafterAgainpopup await connect(deviceInfo.device); if (AppState.shared.popupState) { return; } AppState.shared.popupState = true; await showCupertinoSheet( context: App.appContext(), builder: (context) { return DeviceWifiConfig(); }, ); AppState.shared.popupState = false; break; } } } } String? _getDeviceId(BlueDeviceInfo blueDeviceInfo) { final Map> manufacturerDataMap = blueDeviceInfo.advertisementData[ValueConstant.manufacturerData]; if (manufacturerDataMap.isNotEmpty) { final MapEntry> firstEntry = manufacturerDataMap.entries.first; final List customData = firstEntry.value; final address = customData.map((byte) { return byte.toRadixString(16).padLeft(2, '0').toUpperCase(); }).join(); return address; } return null; } void screenMyDevice(List devices) { if (AppState.shared.deviceMac.isEmpty) { return; } for (final deviceInfo in devices) { final String? deviceMac = _getDeviceId(deviceInfo); if (deviceMac == null) { continue; } final String targetMac = AppState.shared.deviceMac.toUpperCase(); if (deviceMac.toUpperCase() == targetMac) { currentPeripheral = deviceInfo.device; connect(deviceInfo.device); break; } } } void _startCleanupTimer() { _cleanupTimer?.cancel(); _cleanupTimer = Timer.periodic(const Duration(seconds: 2), (timer) { final now = DateTime.now(); final originalCount = discoveredDevices.length; discoveredDevices.removeWhere((d) { return now.difference(d.lastSeen) > _deviceTimeout; }); if (discoveredDevices.length != originalCount) { blufDevicesMonitoring?.call(discoveredDevices); } }); } void stopScan() { FlutterBluePlus.stopScan(); _scanSubscription?.cancel(); _cleanupTimer?.cancel(); } //MARK: - Connection related Future connect(BluetoothDevice peripheral) async { try { _connectionStateSubscription?.cancel(); _connectionStateSubscription = peripheral.connectionState.listen((state) { _handleConnectionState(peripheral, state); }); _resetCharacteristics(); await peripheral.connect( license: License.free, timeout: const Duration(seconds: 15), autoConnect: false, ); } catch (e) { String errorMsg = e is FlutterBluePlusException ? "${e.description} (code: ${e.code})" : e.toString(); connectionStateChanged?.call(peripheral, false); } } void _handleConnectionState( BluetoothDevice peripheral, BluetoothConnectionState state, ) { switch (state) { case BluetoothConnectionState.connected: currentPeripheral = peripheral; _peripheralDidConnect(peripheral); connectionStateChanged?.call(peripheral, true); break; case BluetoothConnectionState.disconnected: final disconnectReason = peripheral.disconnectReason; currentPeripheral = null; _resetCharacteristics(); connectionStateChanged?.call(peripheral, false); //Remove disconnected device MAC from cache so it's treated as new when scanned after reboot for (final deviceInfo in discoveredDevices) { if (deviceInfo.device.remoteId == peripheral.remoteId) { final mac = _getDeviceId(deviceInfo); if (mac != null) { cachedDeviceMacs.remove(mac.toUpperCase()); } break; } } if (autoReconnect) { reconnect(); } break; default: break; } } Future _peripheralDidConnect(BluetoothDevice peripheral) async { await _discoverServices(peripheral); } Future _discoverServices(BluetoothDevice peripheral) async { try { if (peripheral.isDisconnected) { return; } //CallSystemmethoddiscoverservice final services = await peripheral.discoverServices(timeout: 35); for (var s in services) { } //iteratediscoverfeature for (var service in services) { await _discoverCharacteristics(peripheral, service); } } catch (e, stack) { //Add stack print trace } } Future _discoverCharacteristics( BluetoothDevice peripheral, BluetoothService service, ) async { try { final characteristics = service.characteristics; for (var characteristic in characteristics) { characteristicCallback?.call(peripheral, characteristic); await _setupCharacteristicListener(peripheral, characteristic); _saveCharacteristicReference(characteristic); } } catch (e) { } } Future _setupCharacteristicListener( BluetoothDevice peripheral, BluetoothCharacteristic characteristic, ) async { final uuid = characteristic.uuid.toString(); const List needNotifyUuids = [wifiSetCharacteristicUUID]; if (!needNotifyUuids.map((e) => e.toLowerCase()).contains(uuid)) { return; } //onlyhasinwhitelistInsidefeatureValue,Only thenexecuteDownSurface / Sidelistenlogic if (characteristic.properties.notify || characteristic.properties.indicate) { try { bool notifySuccess = await characteristic.setNotifyValue(true); if (notifySuccess) { } else { } } catch (e) { } } //listendatareceive characteristic.lastValueStream.listen((value) { if (value.isEmpty) return; if (uuid == wifiSetCharacteristicUUID.toLowerCase()) { wifiSetCharacteristicCall?.call(value); } }); } void _saveCharacteristicReference(BluetoothCharacteristic characteristic) { final uuid = characteristic.uuid.toString(); switch (uuid) { case headCharacteristicUUID: writeHeadCharacteristic = characteristic; break; case wifiSetCharacteristicUUID: writeWifiSetCharacteristic = characteristic; break; case expressionCharacteristicUUID: writeExpressionCharacteristic = characteristic; break; case writeCharacteristicUUID: writeCharacteristic = characteristic; break; case motionCharacteristicUUID: writeMotionCharacteristic = characteristic; break; case avatarCharacteristicUUID: writeAvatarCharacteristic = characteristic; break; case rgbCharacteristicUUID: writeRGBCharacteristic = characteristic; break; } } Future disconnectCurrentPeripheral() async { final peripheral = currentPeripheral; if (peripheral == null) { return; } try { await peripheral.disconnect(timeout: 35, queue: true, androidDelay: 2000); _resetCharacteristics(); } catch (e) { } } void _resetCharacteristics() { writeWifiSetCharacteristic = null; writeHeadCharacteristic = null; writeExpressionCharacteristic = null; writeCharacteristic = null; } //MARK: - Data sending Future sendHeadData(String data) async { await _sendData(data, writeHeadCharacteristic, "Head data"); } Future sendWifiSetData(String data) async { return await _sendData(data, writeWifiSetCharacteristic, "WiFi set data"); } Future sendExpressionData(String data) async { await _sendData(data, writeExpressionCharacteristic, "Expression data"); } Future sendData(String data) async { await _sendData(data, writeCharacteristic, "Data"); } Future sendDanceData(DanceData data) async { if (writeMotionCharacteristic != null) { final motion = MotionData( pitchServo: data.pitchServo, yawServo: data.yawServo, ); final motionData = utf8.encode(motion.toString()); writeMotionCharacteristic!.write( motionData, withoutResponse: false, allowLongWrite: true, ); } if (writeAvatarCharacteristic != null) { final avatar = ExpressionData( leftEye: data.leftEye, rightEye: data.rightEye, mouth: data.mouth, ); final avatarData = utf8.encode(avatar.toString()); writeAvatarCharacteristic!.write( avatarData, withoutResponse: false, allowLongWrite: true, ); } if (writeRGBCharacteristic != null) { final rgb = RgbData( leftRgbColor: data.leftRgbColor, leftRgbDuration: 0.3, rightRgbColor: data.rightRgbColor, rightRgbDuration: 0.3, ); final rgbData = utf8.encode(rgb.toString()); writeRGBCharacteristic!.write( rgbData, withoutResponse: false, allowLongWrite: true, ); } } Future _sendData( String data, BluetoothCharacteristic? characteristic, String type, ) async { if (characteristic == null) { return false; } final dataToSend = utf8.encode(data); if (dataToSend.isEmpty) { return false; } try { await characteristic.write( dataToSend, withoutResponse: false, allowLongWrite: true, ); return true; } catch (e) { //Send failed = connection broken → can reconnect here if (e.toString().contains("Timed out")) { //cantriggerreconnectlogic } return false; } } Future reconnect() async { if (currentPeripheral != null && currentPeripheral!.isDisconnected) { _resetCharacteristics(); await connect(currentPeripheral!); onReconnectSuccess?.call(currentPeripheral!); } } Function(BluetoothDevice)? onReconnectSuccess; Future readRssi(BluetoothDevice device) async { if (device.isDisconnected) return null; try { return await device.readRssi(timeout: 15); } catch (e) { return null; } } //MARK: - Resource release void dispose() { _scanSubscription?.cancel(); _adapterStateSubscription?.cancel(); _connectionStateSubscription?.cancel(); _cleanupTimer?.cancel(); stopScan(); disconnectCurrentPeripheral(); } } extension ScanResultListExtension on List { void addOrUpdate(ScanResult result) { final index = indexWhere( (element) => element.device.remoteId == result.device.remoteId, ); if (index == -1) { add(result); } else { this[index] = result; } } }