mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
feat: MeshCore SAR - Flutter BLE mesh radio companion app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
308
lib/services/ble/ble_command_queue.dart
Normal file
308
lib/services/ble/ble_command_queue.dart
Normal file
@@ -0,0 +1,308 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Type of response expected from a command
|
||||
enum CommandResponseType {
|
||||
/// No response expected (fire-and-forget)
|
||||
none,
|
||||
|
||||
/// Wait for RESP_CODE_OK (0) or RESP_CODE_ERR (1)
|
||||
ack,
|
||||
|
||||
/// Wait for specific response code with data
|
||||
data,
|
||||
}
|
||||
|
||||
/// Represents a queued BLE command
|
||||
class QueuedCommand<T> {
|
||||
/// The command data to send
|
||||
final Uint8List data;
|
||||
|
||||
/// Command code (first byte of data)
|
||||
final int commandCode;
|
||||
|
||||
/// Type of response expected
|
||||
final CommandResponseType responseType;
|
||||
|
||||
/// Expected response code (for data type commands)
|
||||
final int? expectedResponseCode;
|
||||
|
||||
/// Completer to signal command completion
|
||||
final Completer<T> completer;
|
||||
|
||||
/// Timeout duration for this command
|
||||
final Duration timeout;
|
||||
|
||||
/// Timestamp when command was enqueued
|
||||
final DateTime enqueuedAt;
|
||||
|
||||
QueuedCommand({
|
||||
required this.data,
|
||||
required this.commandCode,
|
||||
required this.responseType,
|
||||
this.expectedResponseCode,
|
||||
required this.completer,
|
||||
required this.timeout,
|
||||
}) : enqueuedAt = DateTime.now();
|
||||
}
|
||||
|
||||
/// BLE command queue with mutex lock and inter-command delays
|
||||
///
|
||||
/// Ensures that:
|
||||
/// - Only one command executes at a time
|
||||
/// - 100ms delay between all commands
|
||||
/// - Commands can wait for ACK or specific responses
|
||||
/// - Timeouts are enforced
|
||||
class BleCommandQueue {
|
||||
// Queue of pending commands
|
||||
final List<QueuedCommand> _queue = [];
|
||||
|
||||
// Mutex lock using Completer
|
||||
Completer<void> _lock = Completer<void>()..complete();
|
||||
|
||||
// Whether queue is currently processing
|
||||
bool _isProcessing = false;
|
||||
|
||||
// Pending responses mapped by command code
|
||||
final Map<int, QueuedCommand> _pendingResponses = {};
|
||||
|
||||
// Last command execution timestamp
|
||||
DateTime? _lastCommandTime;
|
||||
|
||||
// Minimum delay between commands (milliseconds)
|
||||
static const int _minDelayMs = 100;
|
||||
|
||||
// Callbacks
|
||||
VoidCallback? onQueueEmpty;
|
||||
void Function(int queueSize)? onQueueSizeChanged;
|
||||
|
||||
/// Enqueue a command and wait for it to complete
|
||||
///
|
||||
/// [data] - The command data to send
|
||||
/// [commandCode] - Command code (first byte)
|
||||
/// [responseType] - Type of response expected
|
||||
/// [expectedResponseCode] - For data responses, the expected response code
|
||||
/// [timeout] - Maximum time to wait for response
|
||||
///
|
||||
/// Returns a Future that completes when the command receives its response
|
||||
/// or throws TimeoutException if timeout expires.
|
||||
Future<T> enqueue<T>({
|
||||
required Uint8List data,
|
||||
required int commandCode,
|
||||
required CommandResponseType responseType,
|
||||
int? expectedResponseCode,
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
// Determine timeout based on response type
|
||||
final cmdTimeout =
|
||||
timeout ??
|
||||
(responseType == CommandResponseType.data
|
||||
? const Duration(seconds: 10)
|
||||
: const Duration(seconds: 5));
|
||||
|
||||
// Create queued command
|
||||
final command = QueuedCommand<T>(
|
||||
data: data,
|
||||
commandCode: commandCode,
|
||||
responseType: responseType,
|
||||
expectedResponseCode: expectedResponseCode,
|
||||
completer: Completer<T>(),
|
||||
timeout: cmdTimeout,
|
||||
);
|
||||
|
||||
// Add to queue
|
||||
_queue.add(command);
|
||||
onQueueSizeChanged?.call(_queue.length);
|
||||
|
||||
debugPrint(
|
||||
'📋 [CommandQueue] Enqueued command 0x${commandCode.toRadixString(16).padLeft(2, '0')} (queue size: ${_queue.length})',
|
||||
);
|
||||
|
||||
// Start processing if not already running
|
||||
if (!_isProcessing) {
|
||||
_processQueue();
|
||||
}
|
||||
|
||||
// Wait for command to complete or timeout
|
||||
return command.completer.future.timeout(
|
||||
cmdTimeout,
|
||||
onTimeout: () {
|
||||
debugPrint(
|
||||
'⏱️ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out after ${cmdTimeout.inSeconds}s',
|
||||
);
|
||||
_pendingResponses.remove(commandCode);
|
||||
throw TimeoutException(
|
||||
'Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Process the command queue
|
||||
Future<void> _processQueue() async {
|
||||
if (_isProcessing) return;
|
||||
_isProcessing = true;
|
||||
|
||||
while (_queue.isNotEmpty) {
|
||||
// Wait for lock
|
||||
await _lock.future;
|
||||
|
||||
// Get next command
|
||||
final command = _queue.removeAt(0);
|
||||
onQueueSizeChanged?.call(_queue.length);
|
||||
|
||||
try {
|
||||
// Enforce minimum delay between commands
|
||||
if (_lastCommandTime != null) {
|
||||
final elapsed = DateTime.now().difference(_lastCommandTime!);
|
||||
final remainingDelay = _minDelayMs - elapsed.inMilliseconds;
|
||||
|
||||
if (remainingDelay > 0) {
|
||||
debugPrint(
|
||||
'⏸️ [CommandQueue] Waiting ${remainingDelay}ms before next command',
|
||||
);
|
||||
await Future.delayed(Duration(milliseconds: remainingDelay));
|
||||
}
|
||||
}
|
||||
|
||||
// Create new lock for next command
|
||||
_lock = Completer<void>();
|
||||
|
||||
// Register for response if needed
|
||||
if (command.responseType != CommandResponseType.none) {
|
||||
final responseKey = command.responseType == CommandResponseType.ack
|
||||
? command.commandCode
|
||||
: (command.expectedResponseCode ?? command.commandCode);
|
||||
_pendingResponses[responseKey] = command;
|
||||
}
|
||||
|
||||
// Execute command (handled by BleCommandSender)
|
||||
// The completer will be completed by completeCommand() when response arrives
|
||||
debugPrint(
|
||||
'📤 [CommandQueue] Executing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')}',
|
||||
);
|
||||
|
||||
// For fire-and-forget commands, complete immediately
|
||||
if (command.responseType == CommandResponseType.none) {
|
||||
command.completer.complete(null);
|
||||
}
|
||||
|
||||
// Update last command time
|
||||
_lastCommandTime = DateTime.now();
|
||||
|
||||
// Release lock after minimum delay
|
||||
Future.delayed(const Duration(milliseconds: _minDelayMs), () {
|
||||
if (!_lock.isCompleted) {
|
||||
_lock.complete();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('❌ [CommandQueue] Error processing command: $e');
|
||||
if (!command.completer.isCompleted) {
|
||||
command.completer.completeError(e);
|
||||
}
|
||||
// Release lock on error
|
||||
if (!_lock.isCompleted) {
|
||||
_lock.complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_isProcessing = false;
|
||||
onQueueEmpty?.call();
|
||||
debugPrint('✅ [CommandQueue] Queue empty');
|
||||
}
|
||||
|
||||
/// Complete a pending command with response data
|
||||
///
|
||||
/// Called by BleResponseHandler when a response is received
|
||||
void completeCommand<T>(int responseCode, T data) {
|
||||
final command = _pendingResponses.remove(responseCode);
|
||||
if (command != null) {
|
||||
debugPrint(
|
||||
'✅ [CommandQueue] Completing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')} with response 0x${responseCode.toRadixString(16).padLeft(2, '0')}',
|
||||
);
|
||||
if (!command.completer.isCompleted) {
|
||||
command.completer.complete(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete a pending command with error
|
||||
///
|
||||
/// Called by BleResponseHandler when RESP_CODE_ERR is received
|
||||
void completeCommandWithError(
|
||||
int commandCode,
|
||||
String error, {
|
||||
int? errorCode,
|
||||
}) {
|
||||
final command = _pendingResponses.remove(commandCode);
|
||||
if (command != null) {
|
||||
debugPrint(
|
||||
'❌ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} failed: $error (code: $errorCode)',
|
||||
);
|
||||
if (!command.completer.isCompleted) {
|
||||
command.completer.completeError(
|
||||
Exception('Command failed: $error (error code: $errorCode)'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete all currently pending commands with an error
|
||||
///
|
||||
/// Used when RESP_CODE_ERR arrives without a way to identify which command
|
||||
/// caused it. Since the queue processes one command at a time, at most one
|
||||
/// command is pending at any given moment.
|
||||
void completeCurrentCommandWithError(String error, {int? errorCode}) {
|
||||
for (final entry in _pendingResponses.entries.toList()) {
|
||||
final command = _pendingResponses.remove(entry.key);
|
||||
if (command != null && !command.completer.isCompleted) {
|
||||
debugPrint(
|
||||
'❌ [CommandQueue] Command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')} failed: $error (code: $errorCode)',
|
||||
);
|
||||
command.completer.completeError(
|
||||
Exception('Command failed: $error (error code: $errorCode)'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current queue size
|
||||
int get queueSize => _queue.length;
|
||||
|
||||
/// Get number of pending responses
|
||||
int get pendingResponseCount => _pendingResponses.length;
|
||||
|
||||
/// Check if queue is empty
|
||||
bool get isEmpty => _queue.isEmpty;
|
||||
|
||||
/// Check if queue is processing
|
||||
bool get isProcessing => _isProcessing;
|
||||
|
||||
/// Clear all pending commands (use with caution)
|
||||
void clear() {
|
||||
debugPrint(
|
||||
'🗑️ [CommandQueue] Clearing queue (${_queue.length} commands, ${_pendingResponses.length} pending responses)',
|
||||
);
|
||||
|
||||
// Complete all pending commands with error
|
||||
for (final command in _pendingResponses.values) {
|
||||
if (!command.completer.isCompleted) {
|
||||
command.completer.completeError(Exception('Queue cleared'));
|
||||
}
|
||||
}
|
||||
|
||||
_queue.clear();
|
||||
_pendingResponses.clear();
|
||||
onQueueSizeChanged?.call(0);
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
clear();
|
||||
if (!_lock.isCompleted) {
|
||||
_lock.complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
230
lib/services/ble/ble_command_sender.dart
Normal file
230
lib/services/ble/ble_command_sender.dart
Normal file
@@ -0,0 +1,230 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import '../meshcore_opcode_names.dart';
|
||||
import '../../models/ble_packet_log.dart';
|
||||
import 'ble_command_queue.dart';
|
||||
|
||||
/// Callback types for sender events
|
||||
typedef OnErrorCallback = void Function(String error);
|
||||
|
||||
/// Sends commands to the BLE device
|
||||
class BleCommandSender {
|
||||
BluetoothCharacteristic? _rxCharacteristic;
|
||||
int _txPacketCount = 0;
|
||||
final List<BlePacketLog> _packetLogs = [];
|
||||
static const int _maxLogSize = 1000;
|
||||
|
||||
// Command queue for serialization and response waiting
|
||||
final BleCommandQueue _commandQueue = BleCommandQueue();
|
||||
|
||||
// Callbacks
|
||||
OnErrorCallback? onError;
|
||||
VoidCallback? onTxActivity;
|
||||
|
||||
// Getters
|
||||
int get txPacketCount => _txPacketCount;
|
||||
List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs);
|
||||
BleCommandQueue get commandQueue => _commandQueue;
|
||||
|
||||
/// Set the RX characteristic to write to
|
||||
void setRxCharacteristic(BluetoothCharacteristic? characteristic) {
|
||||
_rxCharacteristic = characteristic;
|
||||
}
|
||||
|
||||
/// Write data to RX characteristic (fire-and-forget, no response expected)
|
||||
///
|
||||
/// This method is for commands that don't expect any response.
|
||||
/// The command is queued and executed with proper spacing, but we don't wait
|
||||
/// for any acknowledgment.
|
||||
Future<void> writeData(Uint8List data) async {
|
||||
if (_rxCharacteristic == null) {
|
||||
throw Exception('Not connected');
|
||||
}
|
||||
|
||||
final commandCode = data.isNotEmpty ? data[0] : 0;
|
||||
|
||||
// Enqueue the command (fire-and-forget)
|
||||
await _commandQueue.enqueue<void>(
|
||||
data: data,
|
||||
commandCode: commandCode,
|
||||
responseType: CommandResponseType.none,
|
||||
);
|
||||
|
||||
// Actually send the data
|
||||
await _sendToDevice(data);
|
||||
}
|
||||
|
||||
/// Write data and wait for ACK (RESP_CODE_OK or RESP_CODE_ERR)
|
||||
///
|
||||
/// This method should be used for setup commands that return RESP_CODE_OK (0)
|
||||
/// on success or RESP_CODE_ERR (1) on failure.
|
||||
///
|
||||
/// Examples: setAdvertLatLon, setAdvertName, setRadioParams, etc.
|
||||
Future<void> writeDataAndWaitForAck(Uint8List data) async {
|
||||
if (_rxCharacteristic == null) {
|
||||
throw Exception('Not connected');
|
||||
}
|
||||
|
||||
final commandCode = data.isNotEmpty ? data[0] : 0;
|
||||
|
||||
// Enqueue command but don't await yet — data must be sent to the device
|
||||
// before it can respond with an ACK. Awaiting before send would deadlock.
|
||||
final ackFuture = _commandQueue.enqueue<void>(
|
||||
data: data,
|
||||
commandCode: commandCode,
|
||||
responseType: CommandResponseType.ack,
|
||||
);
|
||||
|
||||
// Actually send the data
|
||||
await _sendToDevice(data);
|
||||
|
||||
// Now wait for the ACK response
|
||||
return ackFuture;
|
||||
}
|
||||
|
||||
/// Write data and wait for specific response
|
||||
///
|
||||
/// This method should be used for query commands that return specific data.
|
||||
///
|
||||
/// Examples:
|
||||
/// - CMD_DEVICE_QUERY → RESP_CODE_DEVICE_INFO
|
||||
/// - CMD_APP_START → RESP_CODE_SELF_INFO
|
||||
/// - CMD_GET_CONTACTS → RESP_CODE_CONTACTS_START
|
||||
Future<T> writeDataAndWaitForResponse<T>(
|
||||
Uint8List data,
|
||||
int expectedResponseCode,
|
||||
) async {
|
||||
if (_rxCharacteristic == null) {
|
||||
throw Exception('Not connected');
|
||||
}
|
||||
|
||||
final commandCode = data.isNotEmpty ? data[0] : 0;
|
||||
|
||||
// Enqueue the command (wait for specific response)
|
||||
final responseFuture = _commandQueue.enqueue<T>(
|
||||
data: data,
|
||||
commandCode: commandCode,
|
||||
responseType: CommandResponseType.data,
|
||||
expectedResponseCode: expectedResponseCode,
|
||||
);
|
||||
|
||||
// Actually send the data
|
||||
await _sendToDevice(data);
|
||||
|
||||
// Wait for response
|
||||
return responseFuture;
|
||||
}
|
||||
|
||||
/// Internal method to actually send data to the BLE device
|
||||
Future<void> _sendToDevice(Uint8List data) async {
|
||||
if (_rxCharacteristic == null) {
|
||||
throw Exception('Not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract command code from first byte
|
||||
final commandCode = data.isNotEmpty ? data[0] : null;
|
||||
final opcodeName = commandCode != null
|
||||
? MeshCoreOpcodeNames.getCommandName(commandCode)
|
||||
: 'UNKNOWN';
|
||||
final opcodeHex = commandCode != null
|
||||
? '0x${commandCode.toRadixString(16).padLeft(2, '0').toUpperCase()}'
|
||||
: 'N/A';
|
||||
|
||||
debugPrint('📤 [TX] Sending command: $opcodeName ($opcodeHex)');
|
||||
debugPrint(' Data size: ${data.length} bytes');
|
||||
debugPrint(
|
||||
' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
|
||||
);
|
||||
|
||||
// Check if the characteristic supports write without response
|
||||
final supportsWriteWithoutResponse =
|
||||
_rxCharacteristic!.properties.writeWithoutResponse;
|
||||
final supportsWrite = _rxCharacteristic!.properties.write;
|
||||
|
||||
if (supportsWriteWithoutResponse) {
|
||||
await _rxCharacteristic!.write(data, withoutResponse: true);
|
||||
} else if (supportsWrite) {
|
||||
await _rxCharacteristic!.write(data, withoutResponse: false);
|
||||
} else {
|
||||
throw Exception('Characteristic does not support write operations');
|
||||
}
|
||||
|
||||
// Log TX packet
|
||||
_logPacket(data, PacketDirection.tx, responseCode: commandCode);
|
||||
|
||||
// Increment TX packet counter and trigger activity indicator
|
||||
_txPacketCount++;
|
||||
onTxActivity?.call();
|
||||
|
||||
debugPrint('✅ [TX] Command sent successfully');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [TX] Write error: $e');
|
||||
onError?.call('Write error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a packet
|
||||
void _logPacket(
|
||||
Uint8List data,
|
||||
PacketDirection direction, {
|
||||
int? responseCode,
|
||||
}) {
|
||||
// Add new packet
|
||||
_packetLogs.add(
|
||||
BlePacketLog(
|
||||
timestamp: DateTime.now(),
|
||||
rawData: data,
|
||||
direction: direction,
|
||||
responseCode: responseCode,
|
||||
description: _getPacketDescription(responseCode),
|
||||
),
|
||||
);
|
||||
|
||||
// Limit log size to prevent memory issues
|
||||
if (_packetLogs.length > _maxLogSize) {
|
||||
_packetLogs.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get human-readable description of packet
|
||||
String? _getPacketDescription(int? code) {
|
||||
// TX packets - command codes
|
||||
switch (code) {
|
||||
case 4: // cmdGetContacts
|
||||
return 'Get Contacts';
|
||||
case 2: // cmdSendTxtMsg
|
||||
return 'Send Text Message';
|
||||
case 3: // cmdSendChannelTxtMsg
|
||||
return 'Send Channel Message';
|
||||
case 39: // cmdSendTelemetryReq
|
||||
return 'Request Telemetry';
|
||||
case 22: // cmdDeviceQuery
|
||||
return 'Device Query';
|
||||
case 1: // cmdAppStart
|
||||
return 'App Start';
|
||||
case 27: // cmdSendStatusReq
|
||||
return 'Status Request';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset packet counter
|
||||
void resetCounter() {
|
||||
_txPacketCount = 0;
|
||||
}
|
||||
|
||||
/// Clear packet logs
|
||||
void clearPacketLogs() {
|
||||
_packetLogs.clear();
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
_commandQueue.dispose();
|
||||
_rxCharacteristic = null;
|
||||
_packetLogs.clear();
|
||||
}
|
||||
}
|
||||
398
lib/services/ble/ble_connection_manager.dart
Normal file
398
lib/services/ble/ble_connection_manager.dart
Normal file
@@ -0,0 +1,398 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import '../meshcore_constants.dart';
|
||||
|
||||
/// Callback types for connection events
|
||||
typedef OnConnectionStateCallback = void Function(bool isConnected);
|
||||
typedef OnErrorCallback = void Function(String error);
|
||||
typedef OnReconnectionAttemptCallback =
|
||||
void Function(int attemptNumber, int maxAttempts);
|
||||
typedef OnRssiUpdateCallback = void Function(int rssi);
|
||||
|
||||
/// Manages BLE connection lifecycle with automatic reconnection
|
||||
class BleConnectionManager {
|
||||
BluetoothDevice? _device;
|
||||
BluetoothCharacteristic? _rxCharacteristic;
|
||||
BluetoothCharacteristic? _txCharacteristic;
|
||||
bool _isConnected = false;
|
||||
|
||||
// Reconnection state
|
||||
bool _reconnectionEnabled = true;
|
||||
bool _isReconnecting = false;
|
||||
int _reconnectionAttempt = 0;
|
||||
Timer? _reconnectionTimer;
|
||||
StreamSubscription<BluetoothConnectionState>? _connectionStateSubscription;
|
||||
|
||||
// RSSI monitoring
|
||||
Timer? _rssiTimer;
|
||||
int? _lastRssi;
|
||||
|
||||
// SAR-optimized reconnection: ~15 minutes total
|
||||
// Pattern: Fast retries first (for temporary issues), then slower retries (for extended disconnections)
|
||||
static const int _maxReconnectionAttempts = 30;
|
||||
static const List<int> _reconnectionDelaysMs = [
|
||||
2000, // 2s - immediate retry
|
||||
3000, // 3s - quick retry
|
||||
5000, // 5s - fast retry
|
||||
10000, // 10s - moderate retry
|
||||
15000, // 15s - longer retry
|
||||
30000, // 30s - extended retry
|
||||
30000, // 30s - keep trying every 30s after this
|
||||
]; // Total: ~15 minutes of reconnection attempts
|
||||
|
||||
// Callbacks
|
||||
OnConnectionStateCallback? onConnectionStateChanged;
|
||||
OnErrorCallback? onError;
|
||||
OnReconnectionAttemptCallback? onReconnectionAttempt;
|
||||
OnRssiUpdateCallback? onRssiUpdate;
|
||||
|
||||
// Getters
|
||||
bool get isConnected => _isConnected;
|
||||
bool get isReconnecting => _isReconnecting;
|
||||
int get reconnectionAttempt => _reconnectionAttempt;
|
||||
int get maxReconnectionAttempts => _maxReconnectionAttempts;
|
||||
BluetoothDevice? get device => _device;
|
||||
BluetoothCharacteristic? get rxCharacteristic => _rxCharacteristic;
|
||||
BluetoothCharacteristic? get txCharacteristic => _txCharacteristic;
|
||||
|
||||
/// Scan for MeshCore devices
|
||||
Stream<ScanResult> scanForDevices({
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) async* {
|
||||
try {
|
||||
debugPrint('🔍 [BLE] Starting scan for MeshCore devices...');
|
||||
debugPrint(' Service UUID: ${MeshCoreConstants.bleServiceUuid}');
|
||||
debugPrint(' Timeout: ${timeout.inSeconds}s');
|
||||
|
||||
await FlutterBluePlus.startScan(
|
||||
timeout: timeout,
|
||||
withServices: [Guid(MeshCoreConstants.bleServiceUuid)],
|
||||
);
|
||||
debugPrint('✅ [BLE] Scan started successfully');
|
||||
|
||||
int deviceCount = 0;
|
||||
await for (final scanResult in FlutterBluePlus.scanResults) {
|
||||
debugPrint(
|
||||
'📡 [BLE] Scan results batch received: ${scanResult.length} results',
|
||||
);
|
||||
for (final result in scanResult) {
|
||||
debugPrint(
|
||||
' Device: ${result.device.platformName} (${result.device.remoteId})',
|
||||
);
|
||||
debugPrint(' RSSI: ${result.rssi}');
|
||||
debugPrint(' Service UUIDs: ${result.advertisementData.serviceUuids}');
|
||||
|
||||
if (result.advertisementData.serviceUuids.contains(
|
||||
Guid(MeshCoreConstants.bleServiceUuid),
|
||||
)) {
|
||||
deviceCount++;
|
||||
debugPrint(' ✅ MeshCore device found! Total: $deviceCount');
|
||||
yield result;
|
||||
} else {
|
||||
debugPrint(' ❌ Not a MeshCore device (service UUID mismatch)');
|
||||
}
|
||||
}
|
||||
}
|
||||
debugPrint('🏁 [BLE] Scan completed. Found $deviceCount MeshCore devices');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [BLE] Scan error: $e');
|
||||
onError?.call('Scan error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to a MeshCore device
|
||||
Future<bool> connect(BluetoothDevice device) async {
|
||||
try {
|
||||
debugPrint(
|
||||
'🔵 [BLE] Starting connection to device: ${device.platformName} (${device.remoteId})',
|
||||
);
|
||||
_device = device;
|
||||
|
||||
// Connect to device
|
||||
debugPrint('🔵 [BLE] Calling device.connect() with 15s timeout...');
|
||||
await device.connect(
|
||||
license: License.free,
|
||||
timeout: const Duration(seconds: 15),
|
||||
mtu: 512,
|
||||
);
|
||||
debugPrint('✅ [BLE] Device connected successfully');
|
||||
|
||||
// Discover services
|
||||
debugPrint('🔵 [BLE] Discovering services...');
|
||||
final services = await device.discoverServices();
|
||||
debugPrint('✅ [BLE] Found ${services.length} services');
|
||||
|
||||
// Log all discovered services for debugging
|
||||
for (final service in services) {
|
||||
debugPrint(' 📋 Service: ${service.uuid}');
|
||||
for (final char in service.characteristics) {
|
||||
debugPrint(' - Characteristic: ${char.uuid}');
|
||||
}
|
||||
}
|
||||
|
||||
// Find MeshCore service
|
||||
debugPrint(
|
||||
'🔵 [BLE] Looking for MeshCore service: ${MeshCoreConstants.bleServiceUuid}',
|
||||
);
|
||||
BluetoothService? meshCoreService;
|
||||
for (final service in services) {
|
||||
if (service.uuid.toString().toLowerCase() ==
|
||||
MeshCoreConstants.bleServiceUuid.toLowerCase()) {
|
||||
meshCoreService = service;
|
||||
debugPrint('✅ [BLE] Found MeshCore service');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (meshCoreService == null) {
|
||||
debugPrint('❌ [BLE] MeshCore service not found!');
|
||||
throw Exception('MeshCore service not found');
|
||||
}
|
||||
|
||||
// Find RX and TX characteristics
|
||||
debugPrint('🔵 [BLE] Looking for RX and TX characteristics...');
|
||||
debugPrint(' RX UUID: ${MeshCoreConstants.bleCharacteristicRxUuid}');
|
||||
debugPrint(' TX UUID: ${MeshCoreConstants.bleCharacteristicTxUuid}');
|
||||
|
||||
for (final characteristic in meshCoreService.characteristics) {
|
||||
final uuid = characteristic.uuid.toString().toLowerCase();
|
||||
debugPrint(' 📋 Checking characteristic: $uuid');
|
||||
|
||||
if (uuid == MeshCoreConstants.bleCharacteristicRxUuid.toLowerCase()) {
|
||||
_rxCharacteristic = characteristic;
|
||||
debugPrint(' ✅ Found RX characteristic');
|
||||
} else if (uuid ==
|
||||
MeshCoreConstants.bleCharacteristicTxUuid.toLowerCase()) {
|
||||
_txCharacteristic = characteristic;
|
||||
debugPrint(' ✅ Found TX characteristic');
|
||||
}
|
||||
}
|
||||
|
||||
if (_rxCharacteristic == null || _txCharacteristic == null) {
|
||||
debugPrint('❌ [BLE] Required characteristics not found!');
|
||||
debugPrint(' RX found: ${_rxCharacteristic != null}');
|
||||
debugPrint(' TX found: ${_txCharacteristic != null}');
|
||||
throw Exception('Required characteristics not found');
|
||||
}
|
||||
|
||||
// Enable notifications on TX characteristic
|
||||
debugPrint('🔵 [BLE] Enabling notifications on TX characteristic...');
|
||||
await _txCharacteristic!.setNotifyValue(true);
|
||||
debugPrint('✅ [BLE] Notifications enabled');
|
||||
|
||||
_isConnected = true;
|
||||
_reconnectionAttempt =
|
||||
0; // Reset reconnection counter on successful connection
|
||||
debugPrint('🔵 [BLE] Notifying connection state change: connected');
|
||||
onConnectionStateChanged?.call(true);
|
||||
|
||||
// Monitor connection state for automatic reconnection
|
||||
_setupConnectionMonitoring();
|
||||
|
||||
// Start RSSI monitoring
|
||||
_startRssiMonitoring();
|
||||
|
||||
debugPrint('✅✅✅ [BLE] Connection completed successfully!');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌❌❌ [BLE] Connection failed: $e');
|
||||
debugPrint('Stack trace: ${StackTrace.current}');
|
||||
onError?.call('Connection error: $e');
|
||||
_isConnected = false;
|
||||
onConnectionStateChanged?.call(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from device
|
||||
Future<void> disconnect() async {
|
||||
try {
|
||||
debugPrint('🔴 [BLE] Disconnect requested by user');
|
||||
// Disable reconnection before disconnecting
|
||||
_reconnectionEnabled = false;
|
||||
_cancelReconnection();
|
||||
_stopRssiMonitoring();
|
||||
|
||||
await _device?.disconnect();
|
||||
_isConnected = false;
|
||||
_device = null;
|
||||
_rxCharacteristic = null;
|
||||
_txCharacteristic = null;
|
||||
onConnectionStateChanged?.call(false);
|
||||
} catch (e) {
|
||||
onError?.call('Disconnect error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Setup connection monitoring for automatic reconnection
|
||||
void _setupConnectionMonitoring() {
|
||||
debugPrint(
|
||||
'🔵 [BLE] Setting up connection monitoring for device: ${_device?.platformName}',
|
||||
);
|
||||
|
||||
// Cancel any existing subscription
|
||||
_connectionStateSubscription?.cancel();
|
||||
|
||||
// Monitor connection state changes
|
||||
_connectionStateSubscription = _device?.connectionState.listen((state) {
|
||||
debugPrint('🔔 [BLE] Connection state changed: $state');
|
||||
|
||||
if (state == BluetoothConnectionState.disconnected) {
|
||||
debugPrint('⚠️ [BLE] Device disconnected unexpectedly!');
|
||||
_isConnected = false;
|
||||
onConnectionStateChanged?.call(false);
|
||||
|
||||
// Attempt automatic reconnection if enabled
|
||||
if (_reconnectionEnabled && !_isReconnecting) {
|
||||
debugPrint('🔄 [BLE] Starting automatic reconnection...');
|
||||
_attemptReconnection();
|
||||
}
|
||||
} else if (state == BluetoothConnectionState.connected) {
|
||||
debugPrint('✅ [BLE] Device connected');
|
||||
_isConnected = true;
|
||||
_reconnectionAttempt = 0;
|
||||
_isReconnecting = false;
|
||||
onConnectionStateChanged?.call(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Attempt to reconnect to the device
|
||||
Future<void> _attemptReconnection() async {
|
||||
if (_device == null || _isReconnecting || !_reconnectionEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isReconnecting = true;
|
||||
_reconnectionAttempt++;
|
||||
|
||||
debugPrint(
|
||||
'🔄 [BLE] Reconnection attempt $_reconnectionAttempt of $_maxReconnectionAttempts',
|
||||
);
|
||||
onReconnectionAttempt?.call(_reconnectionAttempt, _maxReconnectionAttempts);
|
||||
|
||||
if (_reconnectionAttempt > _maxReconnectionAttempts) {
|
||||
debugPrint(
|
||||
'❌ [BLE] Max reconnection attempts reached after ~15 minutes. Giving up.',
|
||||
);
|
||||
_isReconnecting = false;
|
||||
onError?.call(
|
||||
'Connection lost. Unable to reconnect after 15 minutes ($_maxReconnectionAttempts attempts).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate delay with exponential backoff (uses last delay for attempts beyond array length)
|
||||
final delayIndex = (_reconnectionAttempt - 1).clamp(
|
||||
0,
|
||||
_reconnectionDelaysMs.length - 1,
|
||||
);
|
||||
final delayMs = _reconnectionDelaysMs[delayIndex];
|
||||
|
||||
debugPrint(
|
||||
'🔄 [BLE] Waiting ${(delayMs / 1000).toStringAsFixed(0)}s before reconnection attempt $_reconnectionAttempt...',
|
||||
);
|
||||
|
||||
// Wait before attempting reconnection
|
||||
_reconnectionTimer = Timer(Duration(milliseconds: delayMs), () async {
|
||||
if (!_reconnectionEnabled) {
|
||||
debugPrint('🔄 [BLE] Reconnection cancelled by user');
|
||||
_isReconnecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🔄 [BLE] Attempting to reconnect...');
|
||||
|
||||
// Try to reconnect
|
||||
final success = await connect(_device!);
|
||||
|
||||
if (success) {
|
||||
debugPrint('✅ [BLE] Reconnection successful!');
|
||||
_isReconnecting = false;
|
||||
_reconnectionAttempt = 0;
|
||||
} else {
|
||||
debugPrint('❌ [BLE] Reconnection attempt $_reconnectionAttempt failed');
|
||||
_isReconnecting = false;
|
||||
|
||||
// Try again if we haven't reached max attempts
|
||||
if (_reconnectionAttempt < _maxReconnectionAttempts) {
|
||||
_attemptReconnection();
|
||||
} else {
|
||||
onError?.call(
|
||||
'Connection lost. Unable to reconnect after 15 minutes ($_maxReconnectionAttempts attempts).',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [BLE] Reconnection attempt $_reconnectionAttempt error: $e');
|
||||
_isReconnecting = false;
|
||||
|
||||
// Try again if we haven't reached max attempts
|
||||
if (_reconnectionAttempt < _maxReconnectionAttempts) {
|
||||
_attemptReconnection();
|
||||
} else {
|
||||
onError?.call(
|
||||
'Connection lost. Unable to reconnect after 15 minutes: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Cancel ongoing reconnection attempts
|
||||
void _cancelReconnection() {
|
||||
debugPrint('🔴 [BLE] Cancelling reconnection attempts');
|
||||
_reconnectionTimer?.cancel();
|
||||
_reconnectionTimer = null;
|
||||
_isReconnecting = false;
|
||||
_reconnectionAttempt = 0;
|
||||
_connectionStateSubscription?.cancel();
|
||||
_connectionStateSubscription = null;
|
||||
}
|
||||
|
||||
/// Enable automatic reconnection (useful after user manually disconnects)
|
||||
void enableReconnection() {
|
||||
debugPrint('🔵 [BLE] Re-enabling automatic reconnection');
|
||||
_reconnectionEnabled = true;
|
||||
}
|
||||
|
||||
/// Start monitoring RSSI in the background
|
||||
void _startRssiMonitoring() {
|
||||
debugPrint('📡 [BLE] Starting RSSI monitoring (every 5 seconds)');
|
||||
_stopRssiMonitoring(); // Cancel any existing timer
|
||||
|
||||
_rssiTimer = Timer.periodic(const Duration(seconds: 5), (timer) async {
|
||||
if (_device != null && _isConnected) {
|
||||
try {
|
||||
final rssi = await _device!.readRssi();
|
||||
if (_lastRssi != rssi) {
|
||||
_lastRssi = rssi;
|
||||
onRssiUpdate?.call(rssi);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [BLE] Failed to read RSSI: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop RSSI monitoring
|
||||
void _stopRssiMonitoring() {
|
||||
_rssiTimer?.cancel();
|
||||
_rssiTimer = null;
|
||||
_lastRssi = null;
|
||||
debugPrint('📡 [BLE] RSSI monitoring stopped');
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
debugPrint('🔴 [BLE] Disposing BLE connection manager');
|
||||
_cancelReconnection();
|
||||
_stopRssiMonitoring();
|
||||
_device = null;
|
||||
_rxCharacteristic = null;
|
||||
_txCharacteristic = null;
|
||||
}
|
||||
}
|
||||
1184
lib/services/ble/ble_response_handler.dart
Normal file
1184
lib/services/ble/ble_response_handler.dart
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user