refactor: extract BLE protocol stack into meshcore_client package

Move BLE communication layer (command queue, frame parser/builder,
protocol constants, data models) into a standalone reusable Dart package
at ../meshcore_client. App model files become thin re-export wrappers,
keeping all existing consumers working without import changes.
This commit is contained in:
Janez T
2026-02-28 14:09:30 +01:00
parent 6bd7517180
commit c39e0e1344
27 changed files with 46 additions and 5524 deletions

View File

@@ -2,7 +2,7 @@ import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'meshcore_ble_service.dart';
import 'package:meshcore_client/meshcore_client.dart';
/// Background location tracking service for SAR operations
/// Tracks user location and sends periodic updates via MeshCore BLE

View File

@@ -1,308 +0,0 @@
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();
}
}
}

View File

@@ -1,230 +0,0 @@
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();
}
}

View File

@@ -1,398 +0,0 @@
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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,151 +0,0 @@
import 'dart:typed_data';
import 'dart:convert';
/// Buffer reader for parsing MeshCore protocol binary data
class BufferReader {
final Uint8List _buffer;
/// Current read position in the buffer
int offset = 0;
BufferReader(this._buffer);
/// Get remaining bytes count
int get remainingBytesCount => _buffer.length - offset;
/// Check if there are bytes remaining
bool get hasRemaining => offset < _buffer.length;
/// Read a single byte (uint8)
int readByte() {
if (offset >= _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
return _buffer[offset++];
}
/// Read a signed byte (int8)
int readInt8() {
final value = readByte();
return value > 127 ? value - 256 : value;
}
/// Read unsigned 16-bit integer (little-endian)
int readUInt16LE() {
if (offset + 2 > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final value = _buffer[offset] | (_buffer[offset + 1] << 8);
offset += 2;
return value;
}
/// Read signed 16-bit integer (little-endian)
int readInt16LE() {
final value = readUInt16LE();
return value > 32767 ? value - 65536 : value;
}
/// Read unsigned 16-bit integer (big-endian)
int readUInt16BE() {
if (offset + 2 > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final value = (_buffer[offset] << 8) | _buffer[offset + 1];
offset += 2;
return value;
}
/// Read signed 16-bit integer (big-endian)
int readInt16BE() {
final value = readUInt16BE();
return value > 32767 ? value - 65536 : value;
}
/// Read unsigned 32-bit integer (little-endian)
int readUInt32LE() {
if (offset + 4 > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final value = _buffer[offset] |
(_buffer[offset + 1] << 8) |
(_buffer[offset + 2] << 16) |
(_buffer[offset + 3] << 24);
offset += 4;
return value;
}
/// Read signed 32-bit integer (little-endian)
int readInt32LE() {
final value = readUInt32LE();
return value > 2147483647 ? value - 4294967296 : value;
}
/// Read a fixed number of bytes
Uint8List readBytes(int length) {
if (offset + length > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final bytes = _buffer.sublist(offset, offset + length);
offset += length;
return bytes;
}
/// Read remaining bytes
Uint8List readRemainingBytes() {
final bytes = _buffer.sublist(offset);
offset = _buffer.length;
return bytes;
}
/// Read null-terminated string (C-string) with max length
String readCString(int maxLength) {
if (offset + maxLength > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final bytes = _buffer.sublist(offset, offset + maxLength);
offset += maxLength;
// Find null terminator
int nullIndex = bytes.indexOf(0);
if (nullIndex == -1) {
nullIndex = maxLength;
}
// Decode string up to null terminator
return utf8.decode(bytes.sublist(0, nullIndex));
}
/// Read length-prefixed string (remaining bytes as UTF-8)
String readString() {
final bytes = readRemainingBytes();
return utf8.decode(bytes);
}
/// Peek at next byte without advancing offset
int peekByte() {
if (offset >= _buffer.length) {
throw Exception('Buffer overflow: attempting to peek beyond buffer length');
}
return _buffer[offset];
}
/// Skip bytes
void skip(int count) {
if (offset + count > _buffer.length) {
throw Exception('Buffer overflow: attempting to skip beyond buffer length');
}
offset += count;
}
/// Reset offset to beginning
void reset() {
offset = 0;
}
@override
String toString() {
return 'BufferReader(length: ${_buffer.length}, offset: $offset, remaining: $remainingBytesCount)';
}
}

View File

@@ -1,129 +0,0 @@
import 'dart:typed_data';
import 'dart:convert';
/// Buffer writer for creating MeshCore protocol binary data
class BufferWriter {
final List<int> _buffer = [];
/// Get current buffer length
int get length => _buffer.length;
/// Write a single byte (uint8)
void writeByte(int value) {
if (value < 0 || value > 255) {
throw ArgumentError('Byte value must be between 0 and 255');
}
_buffer.add(value);
}
/// Write a signed byte (int8)
void writeInt8(int value) {
if (value < -128 || value > 127) {
throw ArgumentError('Int8 value must be between -128 and 127');
}
_buffer.add(value < 0 ? value + 256 : value);
}
/// Write unsigned 16-bit integer (little-endian)
void writeUInt16LE(int value) {
if (value < 0 || value > 65535) {
throw ArgumentError('UInt16 value must be between 0 and 65535');
}
_buffer.add(value & 0xFF);
_buffer.add((value >> 8) & 0xFF);
}
/// Write signed 16-bit integer (little-endian)
void writeInt16LE(int value) {
if (value < -32768 || value > 32767) {
throw ArgumentError('Int16 value must be between -32768 and 32767');
}
final unsigned = value < 0 ? value + 65536 : value;
writeUInt16LE(unsigned);
}
/// Write unsigned 32-bit integer (little-endian)
void writeUInt32LE(int value) {
if (value < 0 || value > 4294967295) {
throw ArgumentError('UInt32 value must be between 0 and 4294967295');
}
_buffer.add(value & 0xFF);
_buffer.add((value >> 8) & 0xFF);
_buffer.add((value >> 16) & 0xFF);
_buffer.add((value >> 24) & 0xFF);
}
/// Write signed 32-bit integer (little-endian)
void writeInt32LE(int value) {
if (value < -2147483648 || value > 2147483647) {
throw ArgumentError('Int32 value must be between -2147483648 and 2147483647');
}
final unsigned = value < 0 ? value + 4294967296 : value;
writeUInt32LE(unsigned);
}
/// Write bytes from Uint8List
void writeBytes(Uint8List bytes) {
_buffer.addAll(bytes);
}
/// Write bytes from `List<int>`
void writeBytesFromList(List<int> bytes) {
_buffer.addAll(bytes);
}
/// Write null-terminated string (C-string) with fixed length
/// Pads with zeros if string is shorter than maxLength
void writeCString(String str, int maxLength) {
final bytes = utf8.encode(str);
// Ensure we don't exceed max length
final length = bytes.length < maxLength ? bytes.length : maxLength;
// Write string bytes
for (int i = 0; i < length; i++) {
_buffer.add(bytes[i]);
}
// Pad with zeros
for (int i = length; i < maxLength; i++) {
_buffer.add(0);
}
}
/// Write length-prefixed string
void writeString(String str) {
final bytes = utf8.encode(str);
_buffer.addAll(bytes);
}
/// Write string with length prefix (1 byte)
void writeLengthPrefixedString(String str) {
final bytes = utf8.encode(str);
if (bytes.length > 255) {
throw ArgumentError('String too long for length-prefixed format (max 255 bytes)');
}
writeByte(bytes.length);
_buffer.addAll(bytes);
}
/// Get buffer as Uint8List
Uint8List toBytes() {
return Uint8List.fromList(_buffer);
}
/// Clear the buffer
void clear() {
_buffer.clear();
}
/// Get buffer as hex string (for debugging)
String toHexString() {
return _buffer.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
}
@override
String toString() {
return 'BufferWriter(length: $length, hex: ${toHexString()})';
}
}

View File

@@ -1,8 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import '../models/contact_telemetry.dart';
import 'buffer_reader.dart';
import 'meshcore_constants.dart';
import 'package:meshcore_client/meshcore_client.dart';
/// Cayenne LPP (Low Power Payload) data parser
/// Used for decoding telemetry sensor data from MeshCore devices

View File

@@ -2,7 +2,6 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../models/contact_telemetry.dart';
import '../utils/key_comparison.dart';
import 'package:latlong2/latlong.dart';

View File

@@ -2,7 +2,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'meshcore_ble_service.dart';
import 'package:meshcore_client/meshcore_client.dart';
/// Centralized location tracking service for MeshCore SAR
///

View File

@@ -1,748 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../models/ble_packet_log.dart';
import 'ble/ble_connection_manager.dart';
import 'ble/ble_command_sender.dart';
import 'ble/ble_response_handler.dart';
import 'protocol/frame_builder.dart';
import 'meshcore_constants.dart';
/// Callback types for MeshCore events
typedef OnContactCallback = void Function(Contact contact);
typedef OnContactsCompleteCallback = void Function(List<Contact> contacts);
typedef OnMessageCallback = void Function(Message message);
typedef OnTelemetryCallback =
void Function(Uint8List publicKey, Uint8List lppData);
typedef OnSelfInfoCallback = void Function(Map<String, dynamic> selfInfo);
typedef OnDeviceInfoCallback = void Function(Map<String, dynamic> deviceInfo);
typedef OnNoMoreMessagesCallback = void Function();
typedef OnMessageWaitingCallback = void Function();
typedef OnLoginSuccessCallback =
void Function(
Uint8List publicKeyPrefix,
int permissions,
bool isAdmin,
int tag,
);
typedef OnLoginFailCallback = void Function(Uint8List publicKeyPrefix);
typedef OnAdvertReceivedCallback = void Function(Uint8List publicKey);
typedef OnPathUpdatedCallback = void Function(Uint8List publicKey);
typedef OnMessageSentCallback = void Function(
int expectedAckTag,
int suggestedTimeoutMs,
bool isFloodMode,
Uint8List? contactPublicKey,
);
typedef OnMessageDeliveredCallback =
void Function(int ackCode, int roundTripTimeMs);
typedef OnMessageEchoDetectedCallback =
void Function(String messageId, int echoCount, int snrRaw, int rssiDbm);
typedef OnStatusResponseCallback =
void Function(Uint8List publicKeyPrefix, Uint8List statusData);
typedef OnBinaryResponseCallback =
void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData);
typedef OnBatteryAndStorageCallback =
void Function(int millivolts, int? usedKb, int? totalKb);
typedef OnErrorCallback = void Function(String error, {int? errorCode});
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
typedef OnChannelInfoCallback =
void Function(int channelIdx, String channelName, Uint8List secret, int? flags);
typedef OnConnectionStateCallback = void Function(bool isConnected);
typedef OnReconnectionAttemptCallback =
void Function(int attemptNumber, int maxAttempts);
typedef OnRssiUpdateCallback = void Function(int rssi);
/// MeshCore BLE Service - coordinates BLE communication components
class MeshCoreBleService {
// Component instances
final BleConnectionManager _connectionManager = BleConnectionManager();
final BleCommandSender _commandSender = BleCommandSender();
final BleResponseHandler _responseHandler = BleResponseHandler();
// Keepalive timer for iOS background mode
Timer? _keepaliveTimer;
static const Duration _keepaliveInterval = Duration(seconds: 20);
// Event callbacks
OnConnectionStateCallback? onConnectionStateChanged;
OnReconnectionAttemptCallback? onReconnectionAttempt;
OnRssiUpdateCallback? onRssiUpdate;
OnContactCallback? onContactReceived;
OnContactsCompleteCallback? onContactsComplete;
OnMessageCallback? onMessageReceived;
OnTelemetryCallback? onTelemetryReceived;
OnSelfInfoCallback? onSelfInfoReceived;
OnDeviceInfoCallback? onDeviceInfoReceived;
OnNoMoreMessagesCallback? onNoMoreMessages;
OnMessageWaitingCallback? onMessageWaiting;
OnLoginSuccessCallback? onLoginSuccess;
OnLoginFailCallback? onLoginFail;
OnAdvertReceivedCallback? onAdvertReceived;
OnPathUpdatedCallback? onPathUpdated;
OnMessageSentCallback? onMessageSent;
OnMessageDeliveredCallback? onMessageDelivered;
OnMessageEchoDetectedCallback? onMessageEchoDetected;
OnStatusResponseCallback? onStatusResponse;
OnBinaryResponseCallback? onBinaryResponse;
OnBatteryAndStorageCallback? onBatteryAndStorage;
OnErrorCallback? onError;
OnContactNotFoundCallback? onContactNotFound;
OnChannelInfoCallback? onChannelInfoReceived;
void Function(Uint8List publicKey)? onContactDeleted;
VoidCallback? onContactsFull;
// Activity callbacks (for blinking indicators)
VoidCallback? onRxActivity;
VoidCallback? onTxActivity;
// Constructor
MeshCoreBleService() {
_setupCallbacks();
}
// Setup callbacks between components
void _setupCallbacks() {
// Connection manager callbacks
_connectionManager.onConnectionStateChanged = (isConnected) {
if (isConnected) {
_startKeepalive();
} else {
_stopKeepalive();
}
onConnectionStateChanged?.call(isConnected);
};
_connectionManager.onError = (error) {
onError?.call(error);
};
_connectionManager.onReconnectionAttempt = (attemptNumber, maxAttempts) {
debugPrint(
'🔄 [Service] Reconnection attempt $attemptNumber/$maxAttempts',
);
onReconnectionAttempt?.call(attemptNumber, maxAttempts);
};
_connectionManager.onRssiUpdate = (rssi) {
onRssiUpdate?.call(rssi);
};
// Command sender callbacks
_commandSender.onError = (error) {
onError?.call(error);
};
_commandSender.onTxActivity = () {
onTxActivity?.call();
};
// Response handler callbacks
_responseHandler.onContactReceived = (contact) {
debugPrint('🔔 [BleService] onContactReceived - "${contact.advName}" - forwarding to ConnectionProvider');
onContactReceived?.call(contact);
};
_responseHandler.onContactsComplete = (contacts) {
debugPrint('🔔 [BleService] onContactsComplete - ${contacts.length} contacts - forwarding to ConnectionProvider');
onContactsComplete?.call(contacts);
};
_responseHandler.onMessageReceived = (message) {
debugPrint('🔔 [BleService] onMessageReceived - forwarding to ConnectionProvider');
onMessageReceived?.call(message);
};
_responseHandler.onTelemetryReceived = (publicKey, lppData) {
debugPrint('🔔 [BleService] onTelemetryReceived - ${lppData.length} bytes - forwarding to ConnectionProvider');
onTelemetryReceived?.call(publicKey, lppData);
};
_responseHandler.onSelfInfoReceived = (selfInfo) {
// Extract our node hash (first byte of public key) for echo detection
if (selfInfo['publicKey'] != null) {
final publicKey = selfInfo['publicKey'] as Uint8List;
if (publicKey.isNotEmpty) {
_responseHandler.setOurNodeHash(publicKey[0]);
}
}
onSelfInfoReceived?.call(selfInfo);
};
_responseHandler.onDeviceInfoReceived = (deviceInfo) {
onDeviceInfoReceived?.call(deviceInfo);
};
_responseHandler.onNoMoreMessages = () {
onNoMoreMessages?.call();
};
_responseHandler.onMessageWaiting = () {
onMessageWaiting?.call();
};
_responseHandler.onLoginSuccess =
(publicKeyPrefix, permissions, isAdmin, tag) {
onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag);
};
_responseHandler.onLoginFail = (publicKeyPrefix) {
onLoginFail?.call(publicKeyPrefix);
};
_responseHandler.onAdvertReceived = (publicKey) {
debugPrint('🔔 [BleService] onAdvertReceived - forwarding to ConnectionProvider');
onAdvertReceived?.call(publicKey);
};
_responseHandler.onPathUpdated = (publicKey) {
debugPrint('🔔 [BleService] onPathUpdated - forwarding to ConnectionProvider');
onPathUpdated?.call(publicKey);
};
_responseHandler.onMessageSent =
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
onMessageSent?.call(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey);
};
_responseHandler.onMessageDelivered = (ackCode, roundTripTimeMs) {
onMessageDelivered?.call(ackCode, roundTripTimeMs);
};
_responseHandler.onMessageEchoDetected =
(messageId, echoCount, snrRaw, rssiDbm) {
onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm);
};
_responseHandler.onStatusResponse = (publicKeyPrefix, statusData) {
onStatusResponse?.call(publicKeyPrefix, statusData);
};
_responseHandler.onBinaryResponse = (publicKeyPrefix, tag, responseData) {
onBinaryResponse?.call(publicKeyPrefix, tag, responseData);
};
_responseHandler.onBatteryAndStorage = (millivolts, usedKb, totalKb) {
onBatteryAndStorage?.call(millivolts, usedKb, totalKb);
};
_responseHandler.onError = (error, {int? errorCode}) {
onError?.call(error, errorCode: errorCode);
};
_responseHandler.onContactNotFound = (contactPublicKey) {
onContactNotFound?.call(contactPublicKey);
};
_responseHandler.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) {
onChannelInfoReceived?.call(channelIdx, channelName, secret, flags);
};
_responseHandler.onContactDeleted = (publicKey) {
onContactDeleted?.call(publicKey);
};
_responseHandler.onContactsFull = () {
onContactsFull?.call();
};
_responseHandler.onRxActivity = () {
onRxActivity?.call();
};
}
// Getters
bool get isConnected => _connectionManager.isConnected;
bool get isReconnecting => _connectionManager.isReconnecting;
int get reconnectionAttempt => _connectionManager.reconnectionAttempt;
int get maxReconnectionAttempts => _connectionManager.maxReconnectionAttempts;
int get rxPacketCount => _responseHandler.rxPacketCount;
int get txPacketCount => _commandSender.txPacketCount;
List<BlePacketLog> get packetLogs {
// Merge logs from both sender and handler
final allLogs = [
..._commandSender.packetLogs,
..._responseHandler.packetLogs,
];
allLogs.sort((a, b) => a.timestamp.compareTo(b.timestamp));
return allLogs;
}
/// Scan for MeshCore devices
Stream<ScanResult> scanForDevices({
Duration timeout = const Duration(seconds: 10),
}) {
return _connectionManager.scanForDevices(timeout: timeout);
}
/// Connect to a MeshCore device
Future<bool> connect(BluetoothDevice device) async {
final success = await _connectionManager.connect(device);
if (success) {
try {
// Setup command sender with RX characteristic
_commandSender.setRxCharacteristic(_connectionManager.rxCharacteristic);
// Wire up command queue between sender and response handler
_responseHandler.setCommandQueue(_commandSender.commandQueue);
// Setup response handler with TX characteristic
if (_connectionManager.txCharacteristic != null) {
_responseHandler.subscribeToNotifications(
_connectionManager.txCharacteristic!,
);
}
// Send initial device query and wait for responses
await _sendDeviceQuery();
debugPrint('✅ [Service] Device initialization complete');
return true;
} catch (e) {
debugPrint('❌ [Service] Device initialization failed: $e');
// Disconnect on initialization failure
await disconnect();
onError?.call('Device initialization failed: $e');
return false;
}
}
return success;
}
/// Disconnect from device
Future<void> disconnect() async {
await _connectionManager.disconnect();
}
/// Send initial device query and sync clock
Future<void> _sendDeviceQuery() async {
// STEP 1: Send device query FIRST to get device capabilities
// This is the first command to send per protocol documentation
debugPrint(
'🔍 [Service] Querying device information (CMD_DEVICE_QUERY)...',
);
final deviceInfo = await _commandSender
.writeDataAndWaitForResponse<Map<String, dynamic>>(
FrameBuilder.buildDeviceQuery(),
MeshCoreConstants.respDeviceInfo,
);
debugPrint(
'✅ [Service] Device info received: firmware=${deviceInfo['firmwareVersion']}',
);
// STEP 2: Send app start to initialize the app session
// This is the first command after connection per protocol documentation
debugPrint('🚀 [Service] Sending app start (CMD_APP_START)...');
await _commandSender.writeDataAndWaitForResponse<Map<String, dynamic>>(
FrameBuilder.buildAppStart(),
MeshCoreConstants.respSelfInfo,
);
debugPrint('✅ [Service] Self info received: node initialized');
// STEP 3: Set device clock AFTER initialization
// This ensures the device has correct timestamps for all subsequent operations
// Note: This command does not return an ACK, so we use writeData (fire-and-forget)
debugPrint('⏰ [Service] Setting device clock (CMD_SET_DEVICE_TIME)...');
await _commandSender.writeData(FrameBuilder.buildSetDeviceTime());
debugPrint('✅ [Service] Device clock sent (no ACK expected)');
// STEP 4: Sync any waiting messages immediately after connection
// This ensures we receive messages that arrived while disconnected
debugPrint('📬 [Service] Syncing messages (CMD_SYNC_NEXT_MESSAGE)...');
await syncNextMessage();
debugPrint('✅ [Service] Message sync initiated');
}
/// Refresh device info (public method)
Future<void> refreshDeviceInfo() async {
await _sendDeviceQuery();
}
/// Get contacts from device
Future<void> getContacts() async {
await _commandSender.writeData(FrameBuilder.buildGetContacts());
}
/// Get a single contact by public key from device
///
/// This is more efficient than getContacts() when you only need to refresh
/// one specific contact (e.g., after receiving an advertisement).
///
/// The contact will be delivered via the onContactReceived callback.
Future<void> getContactByKey(Uint8List publicKey) async {
debugPrint('🔍 [BLE] Requesting single contact by key:');
debugPrint(
' Public key prefix: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
);
await _commandSender.writeData(FrameBuilder.buildGetContactByKey(publicKey));
}
/// Manually add or update a contact on the companion radio
Future<void> addOrUpdateContact(Contact contact) async {
debugPrint('📝 [BLE] Adding/updating contact on companion radio:');
debugPrint(' Name: ${contact.advName}');
debugPrint(
' Public key prefix: ${contact.publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
debugPrint(' Type: ${contact.type} (${contact.type.value})');
await _commandSender.writeData(FrameBuilder.buildAddUpdateContact(contact));
debugPrint('✅ [BLE] CMD_ADD_UPDATE_CONTACT sent');
}
/// Send text message to contact (DM)
Future<void> sendTextMessage({
required Uint8List contactPublicKey,
required String text,
int textType = 0,
int attempt = 0,
}) async {
if (text.length > 160) {
throw ArgumentError('Text message exceeds 160 character limit');
}
// Track the last contact for auto-recovery if contact not found
_responseHandler.setLastContactPublicKey(contactPublicKey);
await _commandSender.writeData(
FrameBuilder.buildSendTxtMsg(
contactPublicKey: contactPublicKey,
text: text,
textType: textType,
attempt: attempt,
),
);
}
/// Send flood-mode text message to channel
/// Track a sent channel message for echo detection
void trackSentChannelMessage(String messageId) {
debugPrint(
'🔵 [MeshCoreBleService] trackSentChannelMessage called for: $messageId',
);
_responseHandler.trackSentMessage(messageId, null);
}
/// Send a text message to a channel (flood-mode broadcast)
///
/// Channel messages are ephemeral and use flood routing (no ACKs).
/// Use channel 0 for the default public channel.
///
/// Note: Uses fire-and-forget mode since channel messages don't return
/// delivery confirmation (they're broadcast to all nodes).
Future<void> sendChannelMessage({
required int channelIdx,
required String text,
int textType = 0,
}) async {
if (text.length > 160) {
throw ArgumentError('Channel message too long (max ~160 characters)');
}
// Channel messages use fire-and-forget (no ACK expected)
// The firmware responds with RESP_CODE_OK but we don't wait for it
await _commandSender.writeData(
FrameBuilder.buildSendChannelTxtMsg(
channelIdx: channelIdx,
text: text,
textType: textType,
),
);
}
/// Request telemetry (GPS, battery) from contact
Future<void> requestTelemetry(
Uint8List contactPublicKey, {
bool zeroHop = false,
}) async {
await _commandSender.writeData(
FrameBuilder.buildSendTelemetryReq(contactPublicKey, zeroHop: zeroHop),
);
}
/// Send binary request to contact
Future<void> sendBinaryRequest({
required Uint8List contactPublicKey,
required Uint8List requestData,
}) async {
await _commandSender.writeData(
FrameBuilder.buildSendBinaryReq(
contactPublicKey: contactPublicKey,
requestData: requestData,
),
);
}
/// Get battery voltage and storage information
Future<void> getBatteryAndStorage() async {
await _commandSender.writeData(FrameBuilder.buildGetBatteryAndStorage());
}
/// Legacy method name for backward compatibility
@Deprecated('Use getBatteryAndStorage() instead')
Future<void> getBatteryVoltage() async {
await getBatteryAndStorage();
}
/// Sync next message from device queue
Future<void> syncNextMessage() async {
await _commandSender.writeData(FrameBuilder.buildSyncNextMessage());
}
/// Get device time from companion radio
Future<void> getDeviceTime() async {
await _commandSender.writeData(FrameBuilder.buildGetDeviceTime());
}
/// Set device time
Future<void> setDeviceTime() async {
await _commandSender.writeData(FrameBuilder.buildSetDeviceTime());
}
/// Send self advertisement packet to mesh network
Future<void> sendSelfAdvert({bool floodMode = true}) async {
await _commandSender.writeData(
FrameBuilder.buildSendSelfAdvert(floodMode: floodMode),
);
}
/// Set advertised name
Future<void> setAdvertName(String name) async {
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetAdvertName(name),
);
}
/// Set advertised latitude and longitude
Future<void> setAdvertLatLon({
required double latitude,
required double longitude,
}) async {
// This command updates device's advertised location
// Fire-and-forget - no ACK needed since actual broadcast happens via sendSelfAdvert
await _commandSender.writeData(
FrameBuilder.buildSetAdvertLatLon(
latitude: latitude,
longitude: longitude,
),
);
}
/// Set radio parameters
Future<void> setRadioParams({
required int frequency,
required int bandwidth,
required int spreadingFactor,
required int codingRate,
}) async {
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetRadioParams(
frequency: frequency,
bandwidth: bandwidth,
spreadingFactor: spreadingFactor,
codingRate: codingRate,
),
);
}
/// Set transmit power
Future<void> setTxPower(int powerDbm) async {
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetTxPower(powerDbm),
);
}
/// Set other parameters
Future<void> setOtherParams({
required int manualAddContacts,
required int telemetryModes,
required int advertLocationPolicy,
int multiAcks = 0,
}) async {
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetOtherParams(
manualAddContacts: manualAddContacts,
telemetryModes: telemetryModes,
advertLocationPolicy: advertLocationPolicy,
multiAcks: multiAcks,
),
);
}
/// Send login request to room or repeater
Future<void> loginToRoom({
required Uint8List roomPublicKey,
required String password,
}) async {
if (password.length > 15) {
throw ArgumentError('Password exceeds 15 character limit');
}
debugPrint('🔐 [BLE] Preparing login request:');
debugPrint(
' Room public key prefix: ${roomPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
debugPrint(
' Password: ${"*" * password.length} (${password.length} chars)',
);
await _commandSender.writeData(
FrameBuilder.buildSendLogin(
roomPublicKey: roomPublicKey,
password: password,
),
);
}
/// Send status request to repeater or sensor node
Future<void> sendStatusRequest(Uint8List contactPublicKey) async {
debugPrint('📊 [BLE] Preparing status request:');
debugPrint(
' Target node public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
await _commandSender.writeData(
FrameBuilder.buildSendStatusReq(contactPublicKey),
);
}
/// Reset path for a contact - forces next message to flood and re-learn route
Future<void> resetPath(Uint8List contactPublicKey) async {
debugPrint('🔄 [BLE] Resetting path for contact:');
debugPrint(
' Contact public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
await _commandSender.writeData(
FrameBuilder.buildResetPath(contactPublicKey),
);
}
/// Remove a contact from the companion radio
Future<void> removeContact(Uint8List contactPublicKey) async {
debugPrint('🗑️ [BLE] Removing contact from companion radio:');
debugPrint(
' Public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
await _commandSender.writeData(
FrameBuilder.buildRemoveContact(contactPublicKey),
);
debugPrint('✅ [BLE] CMD_REMOVE_CONTACT sent');
}
/// Get information for a specific channel
Future<void> getChannel(int channelIdx) async {
await _commandSender.writeData(FrameBuilder.buildGetChannel(channelIdx));
}
/// Set the name and secret for a specific channel
///
/// The secret must be exactly 16 bytes (128-bit encryption key).
/// For the default public channel (channel 0), use [MeshCoreConstants.defaultPublicChannelSecret].
///
/// Note: Some firmware versions don't send ACK for SET_CHANNEL, so we use
/// fire-and-forget and then verify with GET_CHANNEL.
Future<void> setChannel({
required int channelIdx,
required String channelName,
required List<int> secret,
}) async {
debugPrint('📻 [BLE] Setting channel:');
debugPrint(' Channel index: $channelIdx');
debugPrint(' Channel name: $channelName');
debugPrint(' Secret length: ${secret.length} bytes');
debugPrint(' Secret hex: ${secret.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
// Send SET_CHANNEL command (fire-and-forget, no ACK expected)
final setChannelData = FrameBuilder.buildSetChannel(
channelIdx: channelIdx,
channelName: channelName,
secret: secret,
);
debugPrint(' SET_CHANNEL data (${setChannelData.length} bytes): ${setChannelData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
await _commandSender.writeData(setChannelData);
debugPrint('✅ [BLE] CMD_SET_CHANNEL sent');
// Wait a bit for the device to process
await Future.delayed(const Duration(milliseconds: 200));
// Verify the channel was set by reading it back
debugPrint('🔍 [BLE] Verifying channel was set...');
await getChannel(channelIdx);
}
/// Delete a channel by clearing its slot
///
/// This removes the channel from the device by setting it to an empty name and zeroed secret.
/// The channel slot becomes available for reuse.
///
/// Note: Channel 0 (public channel) cannot be deleted.
Future<void> deleteChannel(int channelIdx) async {
if (channelIdx == 0) {
throw ArgumentError('Cannot delete channel 0 (public channel)');
}
debugPrint('🗑️ [BLE] Deleting channel $channelIdx...');
// Clear channel by setting empty name and zeroed secret
await setChannel(
channelIdx: channelIdx,
channelName: '',
secret: List.filled(16, 0),
);
debugPrint('✅ [BLE] Channel $channelIdx deleted');
}
/// Sync all channels from the device (channels 1-39)
/// Skips channel 0 (public channel) which is implicit and not stored on device
Future<void> syncAllChannels({int maxChannels = 40}) async {
debugPrint('📻 [Service] Syncing channels (1-${maxChannels - 1})...');
// Start from 1 to skip channel 0 (public channel)
// Channel 0 is implicit and handled separately via configurePublicChannel()
for (int i = 1; i < maxChannels; i++) {
await getChannel(i);
// Small delay to avoid overwhelming the device
await Future.delayed(const Duration(milliseconds: 50));
}
debugPrint('✅ [Service] Channel sync complete');
}
/// Clear packet logs
void clearPacketLogs() {
_commandSender.clearPacketLogs();
_responseHandler.clearPacketLogs();
}
/// Reset packet counters
void resetCounters() {
_commandSender.resetCounter();
_responseHandler.resetCounter();
}
/// Start keepalive timer for iOS background mode
/// Periodically syncs messages to keep BLE connection alive and check for new messages
/// This serves dual purpose: prevents iOS from killing idle BLE connections AND
/// provides fallback message sync when push notifications (PUSH_CODE_MSG_WAITING) don't trigger
void _startKeepalive() {
_stopKeepalive(); // Stop any existing timer
debugPrint('🔄 [BLE] Starting keepalive timer (${_keepaliveInterval.inSeconds}s interval)');
_keepaliveTimer = Timer.periodic(_keepaliveInterval, (timer) async {
if (!isConnected) {
debugPrint('⚠️ [BLE] Keepalive: Not connected, stopping timer');
_stopKeepalive();
return;
}
try {
// Sync messages to keep connection alive AND check for new messages
// This is a fallback in case PUSH_CODE_MSG_WAITING doesn't fire
// If no messages waiting, device responds with RESP_CODE_NO_MORE_MSG
await syncNextMessage();
debugPrint('💚 [BLE] Keepalive: Connection maintained & messages synced');
} catch (e) {
debugPrint('⚠️ [BLE] Keepalive error: $e');
// Don't stop timer on error - iOS might throttle commands temporarily
}
});
}
/// Stop keepalive timer
void _stopKeepalive() {
if (_keepaliveTimer != null) {
debugPrint('🛑 [BLE] Stopping keepalive timer');
_keepaliveTimer?.cancel();
_keepaliveTimer = null;
}
}
/// Dispose resources
void dispose() {
_stopKeepalive(); // Clean up keepalive timer
_connectionManager.dispose();
_commandSender.dispose();
_responseHandler.dispose();
}
}

View File

@@ -1,183 +0,0 @@
/// MeshCore BLE and Protocol Constants
class MeshCoreConstants {
// Supported protocol version (firmware uses this to decide V1 vs V3 message frames)
static const int supportedCompanionProtocolVersion = 3;
// BLE Service and Characteristic UUIDs
static const String bleServiceUuid =
'6E400001-B5A3-F393-E0A9-E50E24DCCA9E';
static const String bleCharacteristicRxUuid =
'6E400002-B5A3-F393-E0A9-E50E24DCCA9E'; // Write
static const String bleCharacteristicTxUuid =
'6E400003-B5A3-F393-E0A9-E50E24DCCA9E'; // Notify
// Command Codes (App -> Device)
static const int cmdAppStart = 1;
static const int cmdSendTxtMsg = 2;
static const int cmdSendChannelTxtMsg = 3;
static const int cmdGetContacts = 4;
static const int cmdGetDeviceTime = 5;
static const int cmdSetDeviceTime = 6;
static const int cmdSendSelfAdvert = 7;
static const int cmdSetAdvertName = 8;
static const int cmdAddUpdateContact = 9;
static const int cmdSyncNextMessage = 10;
static const int cmdSetRadioParams = 11;
static const int cmdSetTxPower = 12;
static const int cmdResetPath = 13;
static const int cmdSetAdvertLatLon = 14;
static const int cmdRemoveContact = 15;
static const int cmdShareContact = 16;
static const int cmdExportContact = 17;
static const int cmdImportContact = 18;
static const int cmdReboot = 19;
static const int cmdGetBatteryVoltage = 20;
static const int cmdSetTuningParams = 21;
static const int cmdDeviceQuery = 22;
static const int cmdExportPrivateKey = 23;
static const int cmdImportPrivateKey = 24;
static const int cmdSendRawData = 25;
static const int cmdSendLogin = 26;
static const int cmdSendStatusReq = 27;
static const int cmdHasConnection = 28;
static const int cmdLogout = 29;
static const int cmdGetContactByKey = 30;
static const int cmdGetChannel = 31;
static const int cmdSetChannel = 32;
static const int cmdSignStart = 33;
static const int cmdSignData = 34;
static const int cmdSignFinish = 35;
static const int cmdSendTracePath = 36;
static const int cmdSetDevicePin = 37;
static const int cmdSetOtherParams = 38;
static const int cmdSendTelemetryReq = 39;
static const int cmdGetCustomVars = 40;
static const int cmdSetCustomVar = 41;
static const int cmdGetAdvertPath = 42;
static const int cmdGetTuningParams = 43;
static const int cmdSendBinaryReq = 50;
static const int cmdFactoryReset = 51;
static const int cmdSendPathDiscoveryReq = 52;
static const int cmdSetFloodScope = 54; // v8+
static const int cmdSendControlData = 55; // v8+
static const int cmdGetStats = 56; // v8+
static const int cmdSendAnonReq = 57;
static const int cmdSetAutoaddConfig = 58;
static const int cmdGetAutoaddConfig = 59;
static const int cmdGetAllowedRepeatFreq = 60;
// Response Codes (Device -> App)
static const int respOk = 0;
static const int respErr = 1;
static const int respContactsStart = 2;
static const int respContact = 3;
static const int respEndOfContacts = 4;
static const int respSelfInfo = 5;
static const int respSent = 6;
static const int respContactMsgRecv = 7; // firmware ver < 3
static const int respChannelMsgRecv = 8; // firmware ver < 3
static const int respCurrTime = 9;
static const int respNoMoreMessages = 10;
static const int respExportContact = 11;
static const int respBatteryVoltage = 12;
static const int respDeviceInfo = 13;
static const int respPrivateKey = 14;
static const int respDisabled = 15;
static const int respContactMsgRecvV3 = 16; // firmware ver >= 3 (adds SNR header)
static const int respChannelMsgRecvV3 = 17; // firmware ver >= 3 (adds SNR header)
static const int respChannelInfo = 18;
static const int respSignStart = 19;
static const int respSignature = 20;
static const int respCustomVars = 21;
static const int respAdvertPath = 22;
static const int respTuningParams = 23;
static const int respStats = 24; // v8+
static const int respAutoaddConfig = 25;
static const int respAllowedRepeatFreq = 26;
// Push Codes (Device -> App, unsolicited)
static const int pushAdvert = 0x80;
static const int pushPathUpdated = 0x81;
static const int pushSendConfirmed = 0x82;
static const int pushMsgWaiting = 0x83;
static const int pushRawData = 0x84;
static const int pushLoginSuccess = 0x85;
static const int pushLoginFail = 0x86;
static const int pushStatusResponse = 0x87;
static const int pushLogRxData = 0x88;
static const int pushTraceData = 0x89;
static const int pushNewAdvert = 0x8A;
static const int pushTelemetryResponse = 0x8B;
static const int pushBinaryResponse = 0x8C;
static const int pushPathDiscoveryResponse = 0x8D;
static const int pushControlData = 0x8E; // v8+
static const int pushContactDeleted = 0x8F; // contact overwritten when contacts full
static const int pushContactsFull = 0x90; // contacts storage is full
// Stats sub-types for cmdGetStats
static const int statsTypeCore = 0;
static const int statsTypeRadio = 1;
static const int statsTypePackets = 2;
// Error Codes
static const int errUnsupportedCmd = 1;
static const int errNotFound = 2;
static const int errTableFull = 3;
static const int errBadState = 4;
static const int errFileIoError = 5;
static const int errIllegalArg = 6;
// Advert Types
static const int advTypeNone = 0;
static const int advTypeChat = 1;
static const int advTypeRepeater = 2;
static const int advTypeRoom = 3;
// Self Advert Types
static const int selfAdvertZeroHop = 0;
static const int selfAdvertFlood = 1;
// Text Types
static const int txtTypePlain = 0;
static const int txtTypeCliData = 1;
static const int txtTypeSignedPlain = 2;
// Binary Request Types
static const int binaryReqGetTelemetryData = 0x03;
static const int binaryReqGetAvgMinMax = 0x04;
static const int binaryReqGetAccessList = 0x05;
static const int binaryReqGetNeighbours = 0x06;
// Default Public Channel Secret (128-bit)
// This is the well-known pre-shared key for the public channel (channel 0)
// Hex: 8b3387e9c5cdea6ac9e5edbaa115cd72
// Base64: izOH6cXN6mrJ5e26oRXNcg==
// Source: https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md
static const List<int> defaultPublicChannelSecret = [
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72,
];
// Cayenne LPP Data Types
static const int lppDigitalInput = 0;
static const int lppDigitalOutput = 1;
static const int lppAnalogInput = 2;
static const int lppAnalogOutput = 3;
static const int lppIlluminanceSensor = 101;
static const int lppPresenceSensor = 102;
static const int lppTemperatureSensor = 103;
static const int lppHumiditySensor = 104;
static const int lppAccelerometer = 113;
static const int lppBarometer = 115;
static const int lppVoltageSensor = 116;
static const int lppGyrometer = 134;
static const int lppGps = 136;
// MTU and timing
static const int maxMtuSize = 512;
static const int defaultTimeout = 5000; // 5 seconds
static const int reconnectDelay = 2000; // 2 seconds
static const int telemetryUpdateInterval = 300000; // 5 minutes
MeshCoreConstants._(); // Private constructor to prevent instantiation
}

View File

@@ -1,246 +0,0 @@
import 'meshcore_constants.dart';
/// Maps MeshCore protocol opcodes to human-readable names
class MeshCoreOpcodeNames {
/// Get command name from opcode
static String getCommandName(int opcode) {
switch (opcode) {
case MeshCoreConstants.cmdAppStart:
return 'APP_START';
case MeshCoreConstants.cmdSendTxtMsg:
return 'SEND_TXT_MSG';
case MeshCoreConstants.cmdSendChannelTxtMsg:
return 'SEND_CHANNEL_TXT_MSG';
case MeshCoreConstants.cmdGetContacts:
return 'GET_CONTACTS';
case MeshCoreConstants.cmdGetDeviceTime:
return 'GET_DEVICE_TIME';
case MeshCoreConstants.cmdSetDeviceTime:
return 'SET_DEVICE_TIME';
case MeshCoreConstants.cmdSendSelfAdvert:
return 'SEND_SELF_ADVERT';
case MeshCoreConstants.cmdSetAdvertName:
return 'SET_ADVERT_NAME';
case MeshCoreConstants.cmdAddUpdateContact:
return 'ADD_UPDATE_CONTACT';
case MeshCoreConstants.cmdSyncNextMessage:
return 'SYNC_NEXT_MESSAGE';
case MeshCoreConstants.cmdSetRadioParams:
return 'SET_RADIO_PARAMS';
case MeshCoreConstants.cmdSetTxPower:
return 'SET_TX_POWER';
case MeshCoreConstants.cmdResetPath:
return 'RESET_PATH';
case MeshCoreConstants.cmdSetAdvertLatLon:
return 'SET_ADVERT_LAT_LON';
case MeshCoreConstants.cmdRemoveContact:
return 'REMOVE_CONTACT';
case MeshCoreConstants.cmdShareContact:
return 'SHARE_CONTACT';
case MeshCoreConstants.cmdExportContact:
return 'EXPORT_CONTACT';
case MeshCoreConstants.cmdImportContact:
return 'IMPORT_CONTACT';
case MeshCoreConstants.cmdReboot:
return 'REBOOT';
case MeshCoreConstants.cmdGetBatteryVoltage:
return 'GET_BATTERY_VOLTAGE';
case MeshCoreConstants.cmdSetTuningParams:
return 'SET_TUNING_PARAMS';
case MeshCoreConstants.cmdDeviceQuery:
return 'DEVICE_QUERY';
case MeshCoreConstants.cmdExportPrivateKey:
return 'EXPORT_PRIVATE_KEY';
case MeshCoreConstants.cmdImportPrivateKey:
return 'IMPORT_PRIVATE_KEY';
case MeshCoreConstants.cmdSendRawData:
return 'SEND_RAW_DATA';
case MeshCoreConstants.cmdSendLogin:
return 'SEND_LOGIN';
case MeshCoreConstants.cmdSendStatusReq:
return 'SEND_STATUS_REQ';
case MeshCoreConstants.cmdHasConnection:
return 'HAS_CONNECTION';
case MeshCoreConstants.cmdLogout:
return 'LOGOUT';
case MeshCoreConstants.cmdGetContactByKey:
return 'GET_CONTACT_BY_KEY';
case MeshCoreConstants.cmdGetChannel:
return 'GET_CHANNEL';
case MeshCoreConstants.cmdSetChannel:
return 'SET_CHANNEL';
case MeshCoreConstants.cmdSignStart:
return 'SIGN_START';
case MeshCoreConstants.cmdSignData:
return 'SIGN_DATA';
case MeshCoreConstants.cmdSignFinish:
return 'SIGN_FINISH';
case MeshCoreConstants.cmdSendTracePath:
return 'SEND_TRACE_PATH';
case MeshCoreConstants.cmdSetDevicePin:
return 'SET_DEVICE_PIN';
case MeshCoreConstants.cmdSetOtherParams:
return 'SET_OTHER_PARAMS';
case MeshCoreConstants.cmdSendTelemetryReq:
return 'SEND_TELEMETRY_REQ';
case MeshCoreConstants.cmdGetCustomVars:
return 'GET_CUSTOM_VARS';
case MeshCoreConstants.cmdSetCustomVar:
return 'SET_CUSTOM_VAR';
case MeshCoreConstants.cmdGetAdvertPath:
return 'GET_ADVERT_PATH';
case MeshCoreConstants.cmdGetTuningParams:
return 'GET_TUNING_PARAMS';
case MeshCoreConstants.cmdSendBinaryReq:
return 'SEND_BINARY_REQ';
case MeshCoreConstants.cmdFactoryReset:
return 'FACTORY_RESET';
case MeshCoreConstants.cmdSendPathDiscoveryReq:
return 'SEND_PATH_DISCOVERY_REQ';
case MeshCoreConstants.cmdSetFloodScope:
return 'SET_FLOOD_SCOPE';
case MeshCoreConstants.cmdSendControlData:
return 'SEND_CONTROL_DATA';
case MeshCoreConstants.cmdGetStats:
return 'GET_STATS';
case MeshCoreConstants.cmdSendAnonReq:
return 'SEND_ANON_REQ';
case MeshCoreConstants.cmdSetAutoaddConfig:
return 'SET_AUTOADD_CONFIG';
case MeshCoreConstants.cmdGetAutoaddConfig:
return 'GET_AUTOADD_CONFIG';
case MeshCoreConstants.cmdGetAllowedRepeatFreq:
return 'GET_ALLOWED_REPEAT_FREQ';
default:
return 'CMD_UNKNOWN';
}
}
/// Get response name from opcode
static String getResponseName(int opcode) {
switch (opcode) {
case MeshCoreConstants.respOk:
return 'OK';
case MeshCoreConstants.respErr:
return 'ERROR';
case MeshCoreConstants.respContactsStart:
return 'CONTACTS_START';
case MeshCoreConstants.respContact:
return 'CONTACT';
case MeshCoreConstants.respEndOfContacts:
return 'END_OF_CONTACTS';
case MeshCoreConstants.respSelfInfo:
return 'SELF_INFO';
case MeshCoreConstants.respSent:
return 'SENT';
case MeshCoreConstants.respContactMsgRecv:
return 'CONTACT_MSG_RECV';
case MeshCoreConstants.respChannelMsgRecv:
return 'CHANNEL_MSG_RECV';
case MeshCoreConstants.respCurrTime:
return 'CURR_TIME';
case MeshCoreConstants.respNoMoreMessages:
return 'NO_MORE_MESSAGES';
case MeshCoreConstants.respExportContact:
return 'EXPORT_CONTACT';
case MeshCoreConstants.respBatteryVoltage:
return 'BATTERY_VOLTAGE';
case MeshCoreConstants.respDeviceInfo:
return 'DEVICE_INFO';
case MeshCoreConstants.respPrivateKey:
return 'PRIVATE_KEY';
case MeshCoreConstants.respDisabled:
return 'DISABLED';
case MeshCoreConstants.respContactMsgRecvV3:
return 'CONTACT_MSG_RECV_V3';
case MeshCoreConstants.respChannelMsgRecvV3:
return 'CHANNEL_MSG_RECV_V3';
case MeshCoreConstants.respChannelInfo:
return 'CHANNEL_INFO';
case MeshCoreConstants.respSignStart:
return 'SIGN_START';
case MeshCoreConstants.respSignature:
return 'SIGNATURE';
case MeshCoreConstants.respCustomVars:
return 'CUSTOM_VARS';
case MeshCoreConstants.respAdvertPath:
return 'ADVERT_PATH';
case MeshCoreConstants.respTuningParams:
return 'TUNING_PARAMS';
case MeshCoreConstants.respStats:
return 'STATS';
case MeshCoreConstants.respAutoaddConfig:
return 'AUTOADD_CONFIG';
case MeshCoreConstants.respAllowedRepeatFreq:
return 'ALLOWED_REPEAT_FREQ';
default:
return 'RESP_UNKNOWN';
}
}
/// Get push notification name from opcode
static String getPushName(int opcode) {
switch (opcode) {
case MeshCoreConstants.pushAdvert:
return 'ADVERT';
case MeshCoreConstants.pushPathUpdated:
return 'PATH_UPDATED';
case MeshCoreConstants.pushSendConfirmed:
return 'SEND_CONFIRMED';
case MeshCoreConstants.pushMsgWaiting:
return 'MSG_WAITING';
case MeshCoreConstants.pushRawData:
return 'RAW_DATA';
case MeshCoreConstants.pushLoginSuccess:
return 'LOGIN_SUCCESS';
case MeshCoreConstants.pushLoginFail:
return 'LOGIN_FAIL';
case MeshCoreConstants.pushStatusResponse:
return 'STATUS_RESPONSE';
case MeshCoreConstants.pushLogRxData:
return 'LOG_RX_DATA';
case MeshCoreConstants.pushTraceData:
return 'TRACE_DATA';
case MeshCoreConstants.pushNewAdvert:
return 'NEW_ADVERT';
case MeshCoreConstants.pushTelemetryResponse:
return 'TELEMETRY_RESPONSE';
case MeshCoreConstants.pushBinaryResponse:
return 'BINARY_RESPONSE';
case MeshCoreConstants.pushPathDiscoveryResponse:
return 'PATH_DISCOVERY_RESPONSE';
case MeshCoreConstants.pushControlData:
return 'CONTROL_DATA';
case MeshCoreConstants.pushContactDeleted:
return 'CONTACT_DELETED';
case MeshCoreConstants.pushContactsFull:
return 'CONTACTS_FULL';
default:
return 'PUSH_UNKNOWN';
}
}
/// Get opcode name for any code (tries to determine type automatically)
static String getOpcodeName(int opcode, {bool isTx = false}) {
// If TX (sent to device), it's a command
if (isTx) {
return getCommandName(opcode);
}
// If RX (received from device), determine if it's a push or response
if (opcode >= 0x80) {
return getPushName(opcode);
} else {
return getResponseName(opcode);
}
}
/// Get full opcode description with code in hex
static String getOpcodeDescription(int opcode, {bool isTx = false}) {
final name = getOpcodeName(opcode, isTx: isTx);
final hex = '0x${opcode.toRadixString(16).padLeft(2, '0').toUpperCase()}';
return '$name ($hex)';
}
MeshCoreOpcodeNames._(); // Private constructor to prevent instantiation
}

View File

@@ -1,292 +0,0 @@
import 'dart:convert';
import 'dart:typed_data';
import '../../models/contact.dart';
import '../buffer_writer.dart';
import '../meshcore_constants.dart';
/// Builds outgoing BLE frames for the MeshCore device
class FrameBuilder {
/// Build DeviceQuery command
static Uint8List buildDeviceQuery() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdDeviceQuery);
writer.writeByte(MeshCoreConstants.supportedCompanionProtocolVersion);
return writer.toBytes();
}
/// Build AppStart command
static Uint8List buildAppStart() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdAppStart);
writer.writeByte(1); // appVer
writer.writeBytes(Uint8List(6)); // reserved
writer.writeString('MeshCore SAR'); // appName
return writer.toBytes();
}
/// Build GetContacts command
static Uint8List buildGetContacts() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetContacts);
return writer.toBytes();
}
/// Build GetContactByKey command - retrieves a single contact by public key
static Uint8List buildGetContactByKey(Uint8List publicKey) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetContactByKey); // 0x1E (30)
writer.writeBytes(publicKey); // 32 bytes
return writer.toBytes();
}
/// Build AddUpdateContact command
static Uint8List buildAddUpdateContact(Contact contact) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdAddUpdateContact); // 0x09
writer.writeBytes(contact.publicKey); // 32 bytes
writer.writeByte(contact.type.value); // ADV_TYPE_*
writer.writeByte(contact.flags); // flags
writer.writeInt8(contact.outPathLen); // path length (signed byte)
writer.writeBytes(contact.outPath); // 64 bytes
// Write name as null-terminated string in 32-byte field
final nameBytes = Uint8List(32);
final encoded = utf8.encode(contact.advName);
final copyLen = encoded.length > 31 ? 31 : encoded.length;
nameBytes.setRange(0, copyLen, encoded);
writer.writeBytes(nameBytes);
writer.writeUInt32LE(contact.lastAdvert); // timestamp
writer.writeInt32LE(contact.advLat); // latitude * 1E6
writer.writeInt32LE(contact.advLon); // longitude * 1E6
return writer.toBytes();
}
/// Build SendTxtMsg command
static Uint8List buildSendTxtMsg({
required Uint8List contactPublicKey,
required String text,
int textType = 0,
int attempt = 0,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendTxtMsg); // 0x02
writer.writeByte(textType); // TXT_TYPE_*
writer.writeByte(attempt); // 0-3
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
writer.writeBytes(contactPublicKey.sublist(0, 6));
writer.writeString(text);
return writer.toBytes();
}
/// Build SendChannelTxtMsg command
static Uint8List buildSendChannelTxtMsg({
required int channelIdx,
required String text,
int textType = 0,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendChannelTxtMsg); // 0x03
writer.writeByte(textType); // TXT_TYPE_*
writer.writeByte(channelIdx); // 0 for 'public' channel
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
writer.writeString(text);
return writer.toBytes();
}
/// Build SendTelemetryReq command
/// Requests telemetry (GPS, battery) from a contact
static Uint8List buildSendTelemetryReq(Uint8List contactPublicKey, {bool zeroHop = false}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendTelemetryReq);
writer.writeByte(zeroHop ? 0 : 255);
writer.writeByte(0); // reserved
writer.writeByte(0); // reserved
writer.writeBytes(contactPublicKey);
return writer.toBytes();
}
/// Build SendBinaryReq command
static Uint8List buildSendBinaryReq({
required Uint8List contactPublicKey,
required Uint8List requestData,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendBinaryReq); // 0x32 (50)
writer.writeBytes(contactPublicKey); // 32 bytes
writer.writeBytes(requestData); // request code + params
return writer.toBytes();
}
/// Build GetBatteryVoltage command
static Uint8List buildGetBatteryAndStorage() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetBatteryVoltage);
return writer.toBytes();
}
/// Build SyncNextMessage command
static Uint8List buildSyncNextMessage() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSyncNextMessage);
return writer.toBytes();
}
/// Build GetDeviceTime command
static Uint8List buildGetDeviceTime() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetDeviceTime);
return writer.toBytes();
}
/// Build SetDeviceTime command
static Uint8List buildSetDeviceTime() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetDeviceTime);
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
return writer.toBytes();
}
/// Build SendSelfAdvert command
static Uint8List buildSendSelfAdvert({bool floodMode = true}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendSelfAdvert);
writer.writeByte(floodMode ? MeshCoreConstants.selfAdvertFlood : MeshCoreConstants.selfAdvertZeroHop);
return writer.toBytes();
}
/// Build SetAdvertName command
static Uint8List buildSetAdvertName(String name) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetAdvertName);
writer.writeString(name);
return writer.toBytes();
}
/// Build SetAdvertLatLon command
static Uint8List buildSetAdvertLatLon({
required double latitude,
required double longitude,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetAdvertLatLon);
writer.writeInt32LE((latitude * 1000000).round());
writer.writeInt32LE((longitude * 1000000).round());
return writer.toBytes();
}
/// Build SetRadioParams command
static Uint8List buildSetRadioParams({
required int frequency,
required int bandwidth,
required int spreadingFactor,
required int codingRate,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetRadioParams);
writer.writeUInt32LE(frequency);
writer.writeUInt16LE(bandwidth);
writer.writeByte(spreadingFactor);
writer.writeByte(codingRate);
return writer.toBytes();
}
/// Build SetTxPower command
static Uint8List buildSetTxPower(int powerDbm) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetTxPower);
writer.writeByte(powerDbm);
return writer.toBytes();
}
/// Build SetOtherParams command
static Uint8List buildSetOtherParams({
required int manualAddContacts,
required int telemetryModes,
required int advertLocationPolicy,
int multiAcks = 0,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetOtherParams);
writer.writeByte(manualAddContacts);
writer.writeByte(telemetryModes);
writer.writeByte(advertLocationPolicy);
writer.writeByte(multiAcks);
return writer.toBytes();
}
/// Build SendLogin command
static Uint8List buildSendLogin({
required Uint8List roomPublicKey,
required String password,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendLogin); // 0x1A
writer.writeBytes(roomPublicKey); // 32 bytes
writer.writeString(password); // Max 15 bytes, null-terminated
return writer.toBytes();
}
/// Build SendStatusReq command
static Uint8List buildSendStatusReq(Uint8List contactPublicKey) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendStatusReq); // 0x1B
writer.writeBytes(contactPublicKey); // 32 bytes
return writer.toBytes();
}
/// Build ResetPath command - clears learned path for a contact
static Uint8List buildResetPath(Uint8List contactPublicKey) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdResetPath); // 0x0D (13)
writer.writeBytes(contactPublicKey); // 32 bytes
return writer.toBytes();
}
/// Build RemoveContact command - removes a contact from the device
static Uint8List buildRemoveContact(Uint8List contactPublicKey) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdRemoveContact); // 0x0F (15)
writer.writeBytes(contactPublicKey); // 32 bytes
return writer.toBytes();
}
/// Build GetChannel command - retrieves information for a specific channel
static Uint8List buildGetChannel(int channelIdx) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetChannel); // 0x1F (31)
writer.writeByte(channelIdx); // 0-39 typically
return writer.toBytes();
}
/// Build SetChannel command - sets the name and secret for a specific channel
///
/// Format: [cmd(1)][channel_idx(1)][name(32)][secret(16)]
/// Secret must be exactly 16 bytes (128-bit key)
static Uint8List buildSetChannel({
required int channelIdx,
required String channelName,
required List<int> secret,
}) {
if (secret.length != 16) {
throw ArgumentError('Channel secret must be exactly 16 bytes (got ${secret.length})');
}
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetChannel); // 0x20 (32)
writer.writeByte(channelIdx); // 0-39 typically
// Write channel name as null-terminated string in 32-byte field
final nameBytes = Uint8List(32);
final encoded = utf8.encode(channelName);
final copyLen = encoded.length > 31 ? 31 : encoded.length;
nameBytes.setRange(0, copyLen, encoded);
writer.writeBytes(nameBytes);
// Write 16-byte secret
writer.writeBytes(Uint8List.fromList(secret));
return writer.toBytes();
}
}

View File

@@ -1,453 +0,0 @@
import 'dart:convert';
import 'dart:typed_data';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../buffer_reader.dart';
import '../meshcore_constants.dart';
/// Parses incoming BLE frames from the MeshCore device
class FrameParser {
/// Parse ContactsStart response
static int parseContactsStart(BufferReader reader) {
return reader.readUInt32LE();
}
/// Parse Contact response
static Contact parseContact(BufferReader reader) {
final publicKey = reader.readBytes(32);
final typeByte = reader.readByte();
final type = ContactType.fromValue(typeByte);
final flags = reader.readByte();
final outPathLen = reader.readInt8();
final outPath = reader.readBytes(64);
final advName = reader.readCString(32);
final lastAdvert = reader.readUInt32LE();
final advLat = reader.readInt32LE();
final advLon = reader.readInt32LE();
final lastMod = reader.readUInt32LE();
return Contact(
publicKey: publicKey,
type: type,
flags: flags,
outPathLen: outPathLen,
outPath: outPath,
advName: advName,
lastAdvert: lastAdvert,
advLat: advLat,
advLon: advLon,
lastMod: lastMod,
);
}
/// Parse Sent confirmation response
static Map<String, dynamic> parseSentConfirmation(BufferReader reader) {
if (reader.remainingBytesCount >= 9) {
final sendType = reader.readByte();
final isFloodMode = sendType == 1;
final expectedAckOrTagBytes = reader.readBytes(4);
final expectedAckTag = ByteData.sublistView(Uint8List.fromList(expectedAckOrTagBytes))
.getUint32(0, Endian.little);
final suggestedTimeout = reader.readUInt32LE();
return {
'expectedAckTag': expectedAckTag,
'suggestedTimeout': suggestedTimeout,
'isFloodMode': isFloodMode,
};
}
return {};
}
/// Parse ContactMessage V3 response (firmware ver >= 3)
/// V3 prepends 3 bytes: [snr_scaled(int8)][reserved][reserved]
/// snr_dB = snr_scaled / 4.0
static Message parseContactMessageV3(BufferReader reader) {
reader.readInt8(); // snr scaled by 4 (ignored for now)
reader.readBytes(2); // reserved
return parseContactMessage(reader);
}
/// Parse ChannelMessage V3 response (firmware ver >= 3)
/// V3 prepends 3 bytes: [snr_scaled(int8)][reserved][reserved]
static Message parseChannelMessageV3(BufferReader reader) {
reader.readInt8(); // snr scaled by 4 (ignored for now)
reader.readBytes(2); // reserved
return parseChannelMessage(reader);
}
/// Parse ContactMessage response
static Message parseContactMessage(BufferReader reader) {
final pubKeyPrefix = reader.readBytes(6);
final pathLen = reader.readByte();
final txtTypeByte = reader.readByte();
final txtType = MessageTextType.fromValue(txtTypeByte);
final senderTimestamp = reader.readUInt32LE();
String text;
if (txtType == MessageTextType.signedPlain) {
// Signed message format: [4-byte sender prefix][UTF-8 text]
if (reader.remainingBytesCount >= 4) {
reader.readBytes(4); // Skip extra sender prefix
text = reader.hasRemaining ? reader.readString() : '';
} else {
text = reader.readString();
}
} else {
text = reader.readString();
}
return Message(
id: '${DateTime.now().millisecondsSinceEpoch}_${pubKeyPrefix.map((b) => b.toRadixString(16)).join()}',
messageType: MessageType.contact,
senderPublicKeyPrefix: pubKeyPrefix,
pathLen: pathLen,
textType: txtType,
senderTimestamp: senderTimestamp,
text: text,
receivedAt: DateTime.now(),
);
}
/// Parse ChannelMessage response
static Message parseChannelMessage(BufferReader reader) {
final channelIdx = reader.readByte(); // unsigned 0-255, not signed
final pathLen = reader.readByte();
final txtTypeByte = reader.readByte();
final txtType = MessageTextType.fromValue(txtTypeByte);
final senderTimestamp = reader.readUInt32LE();
String text;
if (txtType == MessageTextType.signedPlain) {
if (reader.remainingBytesCount >= 4) {
reader.readBytes(4); // Skip extra sender prefix
text = reader.hasRemaining ? reader.readString() : '';
} else {
text = reader.readString();
}
} else {
text = reader.readString();
}
// Parse sender name from channel message format: "<sender_name>: <actual_message>"
String? senderName;
String actualMessage = text;
if (text.contains(': ')) {
final colonIndex = text.indexOf(': ');
senderName = text.substring(0, colonIndex);
actualMessage = text.substring(colonIndex + 2); // Skip ": "
}
return Message(
id: '${DateTime.now().millisecondsSinceEpoch}_ch$channelIdx',
messageType: MessageType.channel,
channelIdx: channelIdx,
pathLen: pathLen,
textType: txtType,
senderTimestamp: senderTimestamp,
text: actualMessage, // Store the actual message without sender prefix
senderName: senderName, // Store extracted sender name
receivedAt: DateTime.now(),
);
}
/// Parse TelemetryResponse push
static Map<String, dynamic> parseTelemetryResponse(BufferReader reader) {
reader.readByte(); // reserved
final pubKeyPrefix = reader.readBytes(6);
final lppSensorData = reader.readRemainingBytes();
return {
'publicKeyPrefix': pubKeyPrefix,
'lppSensorData': lppSensorData,
};
}
/// Parse BinaryResponse push
static Map<String, dynamic> parseBinaryResponse(BufferReader reader) {
reader.readByte(); // reserved
final tag = reader.readUInt32LE();
final responseData = reader.readRemainingBytes();
return {
'publicKeyPrefix': Uint8List(6), // Empty prefix
'tag': tag,
'responseData': responseData,
};
}
/// Parse DeviceInfo response
static Map<String, dynamic> parseDeviceInfo(BufferReader reader) {
if (reader.remainingBytesCount < 1) {
return {};
}
final firmwareVersion = reader.readByte();
int? maxContacts;
int? maxChannels;
int? blePin;
if (reader.remainingBytesCount >= 6) {
final maxContactsDiv2 = reader.readByte();
maxContacts = maxContactsDiv2 * 2;
maxChannels = reader.readByte();
blePin = reader.readUInt32LE();
}
String? firmwareBuildDate;
if (reader.remainingBytesCount >= 12) {
final buildDateBytes = reader.readBytes(12);
firmwareBuildDate =
String.fromCharCodes(buildDateBytes.takeWhile((b) => b != 0));
}
String? manufacturerModel;
if (reader.remainingBytesCount >= 40) {
final modelBytes = reader.readBytes(40);
manufacturerModel =
String.fromCharCodes(modelBytes.takeWhile((b) => b != 0));
}
String? semanticVersion;
if (reader.remainingBytesCount >= 20) {
final versionBytes = reader.readBytes(20);
semanticVersion =
String.fromCharCodes(versionBytes.takeWhile((b) => b != 0));
}
return {
'firmwareVersion': firmwareVersion,
'maxContacts': maxContacts,
'maxChannels': maxChannels,
'blePin': blePin,
'firmwareBuildDate': firmwareBuildDate,
'manufacturerModel': manufacturerModel,
'semanticVersion': semanticVersion,
};
}
/// Parse SelfInfo response
static Map<String, dynamic> parseSelfInfo(BufferReader reader) {
if (reader.remainingBytesCount < 54) {
reader.readRemainingBytes();
return {};
}
final deviceType = reader.readByte();
final txPower = reader.readByte();
final maxTxPower = reader.readByte();
final publicKey = reader.readBytes(32);
final advLatBytes = reader.readBytes(4);
final advLat = ByteData.sublistView(Uint8List.fromList(advLatBytes))
.getInt32(0, Endian.little);
final advLonBytes = reader.readBytes(4);
final advLon = ByteData.sublistView(Uint8List.fromList(advLonBytes))
.getInt32(0, Endian.little);
reader.readByte(); // multiAcks (reserved for future use)
reader.readByte(); // advertLocPolicy (reserved for future use)
reader.readByte(); // telemetryModes (reserved for future use)
final manualAddContacts = reader.readByte();
final radioFreqBytes = reader.readBytes(4);
final radioFreq = ByteData.sublistView(Uint8List.fromList(radioFreqBytes))
.getUint32(0, Endian.little);
final radioBwBytes = reader.readBytes(4);
final radioBw = ByteData.sublistView(Uint8List.fromList(radioBwBytes))
.getUint32(0, Endian.little);
final radioSf = reader.readByte();
final radioCr = reader.readByte();
String? selfName;
if (reader.hasRemaining) {
final nameBytes = reader.readRemainingBytes();
selfName = utf8.decode(nameBytes.takeWhile((b) => b != 0).toList());
}
return {
'deviceType': deviceType,
'txPower': txPower,
'maxTxPower': maxTxPower,
'publicKey': publicKey,
'advLat': advLat,
'advLon': advLon,
'manualAddContacts': manualAddContacts == 1,
'radioFreq': radioFreq,
'radioBw': radioBw,
'radioSf': radioSf,
'radioCr': radioCr,
'selfName': selfName,
};
}
/// Parse Advert push
static Uint8List? parseAdvert(BufferReader reader) {
if (reader.remainingBytesCount >= 32) {
return reader.readBytes(32);
}
return null;
}
/// Parse PathUpdated push
static Uint8List? parsePathUpdated(BufferReader reader) {
if (reader.remainingBytesCount >= 32) {
return reader.readBytes(32);
}
return null;
}
/// Parse SendConfirmed push
static Map<String, dynamic> parseSendConfirmed(BufferReader reader) {
if (reader.remainingBytesCount >= 8) {
final ackCodeBytes = reader.readBytes(4);
final ackCode = ByteData.sublistView(Uint8List.fromList(ackCodeBytes))
.getUint32(0, Endian.little);
final roundTripTime = reader.readUInt32LE();
return {
'ackCode': ackCode,
'roundTripTime': roundTripTime,
};
}
return {};
}
/// Parse LoginSuccess push
static Map<String, dynamic> parseLoginSuccess(BufferReader reader) {
if (reader.remainingBytesCount >= 11) {
final permissions = reader.readByte();
final isAdmin = (permissions & 0x01) != 0;
final publicKeyPrefix = reader.readBytes(6);
final tag = reader.readInt32LE();
int? newPermissions;
if (reader.hasRemaining) {
newPermissions = reader.readByte();
}
return {
'publicKeyPrefix': publicKeyPrefix,
'permissions': permissions,
'isAdmin': isAdmin,
'tag': tag,
'newPermissions': newPermissions,
};
}
return {};
}
/// Parse LoginFail push
static Uint8List? parseLoginFail(BufferReader reader) {
if (reader.remainingBytesCount >= 7) {
reader.readByte(); // reserved
return reader.readBytes(6);
}
return null;
}
/// Parse StatusResponse push
static Map<String, dynamic> parseStatusResponse(BufferReader reader) {
if (reader.remainingBytesCount >= 7) {
reader.readByte(); // reserved
final publicKeyPrefix = reader.readBytes(6);
final statusData = reader.readRemainingBytes();
return {
'publicKeyPrefix': publicKeyPrefix,
'statusData': statusData,
};
}
return {};
}
/// Parse CurrentTime response
static int? parseCurrentTime(BufferReader reader) {
if (reader.remainingBytesCount >= 4) {
return reader.readUInt32LE();
}
return null;
}
/// Parse BatteryAndStorage response
static Map<String, dynamic> parseBatteryAndStorage(BufferReader reader) {
if (reader.remainingBytesCount >= 2) {
final millivolts = reader.readUInt16LE();
int? usedKb;
int? totalKb;
if (reader.remainingBytesCount >= 8) {
usedKb = reader.readUInt32LE();
totalKb = reader.readUInt32LE();
} else if (reader.remainingBytesCount >= 4) {
usedKb = reader.readUInt32LE();
}
return {
'millivolts': millivolts,
'usedKb': usedKb,
'totalKb': totalKb,
};
}
return {};
}
/// Parse Error response
static int? parseError(BufferReader reader) {
if (reader.hasRemaining) {
return reader.readByte();
}
return null;
}
/// Parse ChannelInfo response
static Map<String, dynamic> parseChannelInfo(BufferReader reader) {
// Format: [channel_idx(1)][name(32)][secret(16)][flags(1)?]
// Minimum: 1 + 32 + 16 = 49 bytes (flags is optional)
if (reader.remainingBytesCount < 49) {
return {};
}
final channelIdx = reader.readByte();
final channelName = reader.readCString(32);
final secret = reader.readBytes(16);
// Flags field is optional (some firmware versions don't include it)
int? flags;
if (reader.remainingBytesCount >= 1) {
flags = reader.readByte();
}
return {
'channelIdx': channelIdx,
'channelName': channelName,
'secret': secret,
'flags': flags,
};
}
/// Get error message from error code
static String getErrorMessage(int errorCode) {
switch (errorCode) {
case MeshCoreConstants.errUnsupportedCmd:
return 'Unsupported command';
case MeshCoreConstants.errNotFound:
return 'Not found';
case MeshCoreConstants.errTableFull:
return 'Table full';
case MeshCoreConstants.errBadState:
return 'Bad state';
case MeshCoreConstants.errFileIoError:
return 'File I/O error';
case MeshCoreConstants.errIllegalArg:
return 'Illegal argument';
default:
return 'Error code: $errorCode';
}
}
}