diff --git a/lib/models/advert_location.dart b/lib/models/advert_location.dart index 79c814a..6a2b55d 100644 --- a/lib/models/advert_location.dart +++ b/lib/models/advert_location.dart @@ -1,40 +1 @@ -import 'package:latlong2/latlong.dart'; - -/// Single advertisement location point in a contact's movement history -class AdvertLocation { - final LatLng location; - final DateTime timestamp; - - AdvertLocation({ - required this.location, - required this.timestamp, - }); - - /// Get friendly time ago display - String get timeAgo { - final diff = DateTime.now().difference(timestamp); - if (diff.inMinutes < 1) return 'Just now'; - if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; - if (diff.inHours < 24) return '${diff.inHours}h ago'; - return '${diff.inDays}d ago'; - } - - @override - String toString() { - return 'AdvertLocation(lat: ${location.latitude.toStringAsFixed(6)}, ' - 'lon: ${location.longitude.toStringAsFixed(6)}, ' - 'time: ${timestamp.toIso8601String()})'; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is AdvertLocation && - other.location.latitude == location.latitude && - other.location.longitude == location.longitude && - other.timestamp == timestamp; - } - - @override - int get hashCode => Object.hash(location.latitude, location.longitude, timestamp); -} +export 'package:meshcore_client/meshcore_client.dart' show AdvertLocation; diff --git a/lib/models/ble_packet_log.dart b/lib/models/ble_packet_log.dart index 6b3760a..5c92ee3 100644 --- a/lib/models/ble_packet_log.dart +++ b/lib/models/ble_packet_log.dart @@ -1,121 +1,2 @@ -import 'dart:typed_data'; -import '../services/meshcore_opcode_names.dart'; - -/// Decoded LOG_RX_DATA packet structure -class LogRxDataInfo { - final int? airtimeMs; - final Uint8List? senderPublicKey; - final int? ackCode; - final List embeddedStrings; - final double entropy; - final bool isLikelyEncrypted; - final double? snrDb; // Signal-to-Noise Ratio in dB - final int? rssiDbm; // Received Signal Strength Indicator in dBm - - LogRxDataInfo({ - this.airtimeMs, - this.senderPublicKey, - this.ackCode, - this.embeddedStrings = const [], - required this.entropy, - required this.isLikelyEncrypted, - this.snrDb, - this.rssiDbm, - }); - - /// Get sender public key as hex string (short) - String? get senderKeyShort { - if (senderPublicKey == null || senderPublicKey!.length < 6) return null; - return senderPublicKey! - .sublist(0, 6) - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(':'); - } - - String get summary { - final parts = []; - if (rssiDbm != null) parts.add('RSSI:${rssiDbm}dBm'); - final snr = snrDb; - if (snr != null) parts.add('SNR:${snr.toStringAsFixed(1)}dB'); - if (airtimeMs != null) parts.add('airtime:${airtimeMs}ms'); - if (ackCode != null) parts.add('ACK:$ackCode'); - if (senderKeyShort != null) parts.add('from:$senderKeyShort'); - if (embeddedStrings.isNotEmpty) parts.add('strings:${embeddedStrings.length}'); - if (isLikelyEncrypted) parts.add('encrypted'); - return parts.join(', '); - } -} - -/// Represents a logged BLE packet with timestamp and metadata -class BlePacketLog { - final DateTime timestamp; - final Uint8List rawData; - final PacketDirection direction; - final int? responseCode; - final String? description; - final LogRxDataInfo? logRxDataInfo; // Decoded LOG_RX_DATA information - - BlePacketLog({ - required this.timestamp, - required this.rawData, - required this.direction, - this.responseCode, - this.description, - this.logRxDataInfo, - }); - - /// Convert raw data to hex string for display - String get hexData { - return rawData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' '); - } - - /// Get opcode name for this packet - String get opcodeName { - if (responseCode == null) return 'N/A'; - return MeshCoreOpcodeNames.getOpcodeName( - responseCode!, - isTx: direction == PacketDirection.tx, - ); - } - - /// Get full opcode description (name + hex code) - String get opcodeDescription { - if (responseCode == null) return 'N/A'; - return MeshCoreOpcodeNames.getOpcodeDescription( - responseCode!, - isTx: direction == PacketDirection.tx, - ); - } - - /// Get short summary of the packet - String get summary { - final dir = direction == PacketDirection.rx ? 'RX' : 'TX'; - final code = responseCode != null ? '0x${responseCode!.toRadixString(16).padLeft(2, '0')}' : 'N/A'; - final name = responseCode != null ? opcodeName : ''; - return '[$dir] $name Code: $code, Size: ${rawData.length} bytes'; - } - - /// Convert to CSV format for export - String toCsvRow() { - final dir = direction == PacketDirection.rx ? 'RX' : 'TX'; - final code = responseCode?.toString() ?? ''; - final name = responseCode != null ? opcodeName : ''; - final hex = hexData; - final desc = description ?? ''; - return '${timestamp.toIso8601String()},$dir,${rawData.length},$name,$code,"$hex","$desc"'; - } - - /// Convert to human-readable log format - String toLogString() { - final dir = direction == PacketDirection.rx ? 'RX' : 'TX'; - final code = responseCode != null ? ' [$opcodeDescription]' : ''; - final desc = description != null ? ' - $description' : ''; - final logRxInfo = logRxDataInfo != null ? ' [${logRxDataInfo!.summary}]' : ''; - return '${timestamp.toIso8601String()} [$dir]$code ${rawData.length} bytes: $hexData$desc$logRxInfo'; - } -} - -enum PacketDirection { - rx, // Received from device - tx, // Sent to device -} +export 'package:meshcore_client/meshcore_client.dart' + show BlePacketLog, PacketDirection, LogRxDataInfo; diff --git a/lib/models/contact.dart b/lib/models/contact.dart index a596617..50dd7f1 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -1,364 +1,17 @@ -import 'dart:math'; -import 'dart:typed_data'; -import 'package:latlong2/latlong.dart'; +export 'package:meshcore_client/meshcore_client.dart' + show Contact, ContactType, ContactTelemetry, AdvertLocation; + import 'package:flutter/material.dart'; -import 'contact_telemetry.dart'; -import 'advert_location.dart'; import '../l10n/app_localizations.dart'; +import 'package:meshcore_client/meshcore_client.dart'; -/// MeshCore contact types -enum ContactType { - none(0), - chat(1), - repeater(2), - room(3), - channel(99); // Virtual type for public channel (not from protocol) - - const ContactType(this.value); - final int value; - - static ContactType fromValue(int value) { - return ContactType.values.firstWhere( - (e) => e.value == value, - orElse: () => ContactType.none, - ); - } - - String get displayName { - switch (this) { - case ContactType.chat: - return 'Chat'; - case ContactType.repeater: - return 'Repeater'; - case ContactType.room: - return 'Room'; - case ContactType.channel: - return 'Channel'; - default: - return 'Unknown'; - } - } -} - -/// MeshCore contact model -class Contact { - final Uint8List publicKey; - final ContactType type; - final int flags; - final int outPathLen; - final Uint8List outPath; - final String advName; - final int lastAdvert; // Unix timestamp - final int advLat; // Latitude as int32 - final int advLon; // Longitude as int32 - final int lastMod; // Unix timestamp - - // Telemetry data (updated separately) - ContactTelemetry? telemetry; - - // Advertisement location history (most recent first) - final List advertHistory; - - // UI state tracking - final bool isNew; // Whether contact is newly added and not yet viewed - - Contact({ - required this.publicKey, - required this.type, - required this.flags, - required this.outPathLen, - required this.outPath, - required this.advName, - required this.lastAdvert, - required this.advLat, - required this.advLon, - required this.lastMod, - this.telemetry, - List? advertHistory, - this.isNew = false, - }) : advertHistory = advertHistory ?? []; - - /// Get public key as hex string (first 8 bytes) - String get publicKeyShort { - if (publicKey.length < 8) return ''; - return publicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); - } - - /// Get full public key as hex string - String get publicKeyHex { - return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); - } - - /// Get public key prefix (first 6 bytes) for room login matching - Uint8List get publicKeyPrefix { - if (publicKey.length < 6) return publicKey; - return publicKey.sublist(0, 6); - } - - /// Convert advLat/advLon to LatLng - LatLng? get advertLocation { - if (advLat == 0 && advLon == 0) return null; - // Convert from int32 to double (degrees) - final lat = advLat / 1e6; - final lon = advLon / 1e6; - return LatLng(lat, lon); - } - - /// Get display location (prefer telemetry over advert) - LatLng? get displayLocation { - if (telemetry?.gpsLocation != null && telemetry!.isRecent) { - return telemetry!.gpsLocation; - } - return advertLocation; - } - - /// Get display battery (from telemetry or null) - double? get displayBattery { - return telemetry?.batteryPercentage; - } - - /// Check if contact is a chat type (team member) - bool get isChat => type == ContactType.chat; - - /// Check if contact is a repeater - bool get isRepeater => type == ContactType.repeater; - - /// Check if contact is a room (persistent storage) - bool get isRoom => type == ContactType.room; - - /// Check if contact is a channel (ephemeral broadcast) - bool get isChannel => type == ContactType.channel; - - /// Get last seen time - DateTime get lastSeenTime { - return DateTime.fromMillisecondsSinceEpoch(lastAdvert * 1000); - } - - /// Get last modified time - DateTime get lastModifiedTime { - return DateTime.fromMillisecondsSinceEpoch(lastMod * 1000); - } - - /// Check if contact was seen recently (within last 10 minutes) - bool get isRecentlySeen { - return DateTime.now().difference(lastSeenTime).inMinutes < 10; - } - - /// Get friendly time since last seen - String get timeSinceLastSeen { - final diff = DateTime.now().difference(lastSeenTime); - if (diff.inMinutes < 1) return 'Just now'; - if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; - if (diff.inHours < 24) return '${diff.inHours}h ago'; - return '${diff.inDays}d ago'; - } - - /// Get time when location was last updated - DateTime? get locationUpdateTime { - // Prefer telemetry timestamp if available - if (telemetry?.gpsLocation != null) { - return telemetry!.timestamp; - } - // Fall back to lastAdvert time if using advertised location - if (advertLocation != null) { - return lastSeenTime; - } - return null; - } - - /// Get friendly time since location was last updated - String get timeSinceLocationUpdate { - final updateTime = locationUpdateTime; - if (updateTime == null) return 'Unknown'; - - final diff = DateTime.now().difference(updateTime); - if (diff.inMinutes < 1) return 'Now'; - if (diff.inMinutes < 60) return '${diff.inMinutes}m'; - if (diff.inHours < 24) return '${diff.inHours}h'; - return '${diff.inDays}d'; - } - - /// Extract role emoji from name (e.g., "πŸ§‘πŸ»β€πŸš’Janez" β†’ "πŸ§‘πŸ»β€πŸš’") - /// Returns null if no emoji at start of name - String? get roleEmoji { - if (advName.isEmpty) return null; - - // Get the first character/grapheme cluster (which could be a complex emoji) - final firstChar = advName.characters.first; - - // Check if it's an emoji (basic check - emojis are typically in certain Unicode ranges) - final firstCodeUnit = firstChar.runes.first; - - // Emoji ranges (simplified check): - // 0x1F300-0x1F9FF: Misc Symbols and Pictographs, Emoticons, Transport, etc. - // 0x2600-0x26FF: Misc symbols - // 0x2700-0x27BF: Dingbats - // 0xFE00-0xFE0F: Variation Selectors - // 0x1F900-0x1F9FF: Supplemental Symbols and Pictographs - if ((firstCodeUnit >= 0x1F300 && firstCodeUnit <= 0x1F9FF) || - (firstCodeUnit >= 0x2600 && firstCodeUnit <= 0x27BF) || - (firstCodeUnit >= 0x1F600 && firstCodeUnit <= 0x1F64F)) { - return firstChar; - } - - return null; - } - - /// Get display name without role emoji (e.g., "πŸ§‘πŸ»β€πŸš’Janez" β†’ "Janez") - /// If no emoji, returns full advName - String get displayName { - final emoji = roleEmoji; - if (emoji == null) return advName; - - // Remove the emoji from the beginning - return advName.substring(emoji.length).trim(); - } - - /// Check if this contact is the Public Channel (all-zeros public key) - bool get isPublicChannel => - publicKeyHex == '0000000000000000000000000000000000000000000000000000000000000000'; - - /// Get localized display name (for Public Channel and other special contacts) +extension ContactLocalization on Contact { + /// Returns the localized display name for special contacts (e.g. Public Channel). + /// For all other contacts, returns [displayName]. String getLocalizedDisplayName(BuildContext context) { - // Check if this is the Public Channel (all-zeros public key) if (isPublicChannel) { return AppLocalizations.of(context)!.publicChannel; } - // For all other contacts, use the regular display name return displayName; } - - /// Check if contact has a learned routing path - /// When true, messages will use direct routing. When false, messages will use flood mode. - /// outPathLen: -1 = unknown/not learned, 0 = direct (zero hops), 1+ = multi-hop path - bool get hasPath => outPathLen >= 0 && outPathLen <= 64; - - /// Get path description for UI display - String get pathDescription { - if (!hasPath) { - // -1 (0xFF) indicates path not learned yet - return 'No path (flood mode)'; - } - - // outPathLen = 0 means direct connection with zero hops - // outPathLen >= 1 means path with N hops - if (outPathLen == 0) { - return 'Direct (0 hops)'; - } else if (outPathLen == 1) { - return 'Direct (1 hop)'; - } else if (outPathLen <= 3) { - return 'Good path ($outPathLen hops)'; - } else if (outPathLen <= 5) { - return 'Medium path ($outPathLen hops)'; - } else { - return 'Long path ($outPathLen hops)'; - } - } - - /// Get path quality indicator (0-5 scale, higher is better) - /// -1 means no path (will use flood mode) - int get pathQuality { - if (!hasPath) return -1; - if (outPathLen == 0) return 5; // Direct connection (0 hops) - if (outPathLen == 1) return 4; // 1 hop - if (outPathLen <= 2) return 3; // 2 hops - if (outPathLen <= 3) return 2; // 3 hops - if (outPathLen <= 4) return 1; // 4 hops - return 0; // 5+ hops - } - - /// Add a new advertisement location to history (maintains max 1000 points) - /// - /// Implements location dithering to avoid storing redundant points: - /// - Only stores points that are β‰₯1 meter apart (max meter accuracy) - /// - Prevents trail clutter when contact is stationary or moving slowly - /// - Maintains chronological order (most recent first) - Contact addAdvertLocation(LatLng location, DateTime timestamp) { - final newPoint = AdvertLocation(location: location, timestamp: timestamp); - - // Dithering: Skip points within 1 meter of the last recorded position - // This provides max meter accuracy while avoiding redundant data - if (advertHistory.isNotEmpty) { - final lastPoint = advertHistory.first; - final distance = _calculateDistance(lastPoint.location, location); - - // If less than 1 meter apart, skip this point (location dithering) - if (distance < 1.0) { - return this; - } - } - - // Add new point at the beginning (most recent first) - final updatedHistory = [newPoint, ...advertHistory]; - - // Keep only the most recent 1000 points to limit memory usage - final trimmedHistory = updatedHistory.length > 1000 - ? updatedHistory.sublist(0, 1000) - : updatedHistory; - - return copyWith(advertHistory: trimmedHistory); - } - - /// Calculate distance between two points in meters (Haversine formula) - double _calculateDistance(LatLng point1, LatLng point2) { - const double earthRadius = 6371000; // meters - final lat1 = point1.latitude * (pi / 180); - final lat2 = point2.latitude * (pi / 180); - final dLat = (point2.latitude - point1.latitude) * (pi / 180); - final dLon = (point2.longitude - point1.longitude) * (pi / 180); - - final a = sin(dLat / 2) * sin(dLat / 2) + - cos(lat1) * cos(lat2) * - sin(dLon / 2) * sin(dLon / 2); - final c = 2 * atan2(sqrt(a), sqrt(1 - a)); - - return earthRadius * c; - } - - Contact copyWith({ - Uint8List? publicKey, - ContactType? type, - int? flags, - int? outPathLen, - Uint8List? outPath, - String? advName, - int? lastAdvert, - int? advLat, - int? advLon, - int? lastMod, - ContactTelemetry? telemetry, - List? advertHistory, - bool? isNew, - }) { - return Contact( - publicKey: publicKey ?? this.publicKey, - type: type ?? this.type, - flags: flags ?? this.flags, - outPathLen: outPathLen ?? this.outPathLen, - outPath: outPath ?? this.outPath, - advName: advName ?? this.advName, - lastAdvert: lastAdvert ?? this.lastAdvert, - advLat: advLat ?? this.advLat, - advLon: advLon ?? this.advLon, - lastMod: lastMod ?? this.lastMod, - telemetry: telemetry ?? this.telemetry, - advertHistory: advertHistory ?? this.advertHistory, - isNew: isNew ?? this.isNew, - ); - } - - @override - String toString() { - return 'Contact(name: $advName, type: ${type.displayName}, key: $publicKeyShort)'; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is Contact && - publicKeyHex == other.publicKeyHex; - } - - @override - int get hashCode => publicKeyHex.hashCode; } diff --git a/lib/models/contact_telemetry.dart b/lib/models/contact_telemetry.dart index e146cb9..ad20add 100644 --- a/lib/models/contact_telemetry.dart +++ b/lib/models/contact_telemetry.dart @@ -1,76 +1 @@ -import 'package:latlong2/latlong.dart'; - -/// Contact telemetry data from MeshCore device -class ContactTelemetry { - final LatLng? gpsLocation; - final double? batteryPercentage; - final double? batteryMilliVolts; - final double? temperature; - final DateTime timestamp; - - // Additional sensor data - final double? humidity; - final double? pressure; - final Map? extraSensorData; - - ContactTelemetry({ - this.gpsLocation, - this.batteryPercentage, - this.batteryMilliVolts, - this.temperature, - required this.timestamp, - this.humidity, - this.pressure, - this.extraSensorData, - }); - - /// Check if telemetry data is recent (within last 5 minutes) - bool get isRecent { - return DateTime.now().difference(timestamp).inMinutes < 5; - } - - /// Check if battery level is low (< 20%) - bool get isLowBattery { - return batteryPercentage != null && batteryPercentage! < 20.0; - } - - /// Check if battery level is critical (< 10%) - bool get isCriticalBattery { - return batteryPercentage != null && batteryPercentage! < 10.0; - } - - /// Get battery status color indicator - String get batteryStatus { - if (batteryPercentage == null) return 'unknown'; - if (batteryPercentage! > 50) return 'good'; - if (batteryPercentage! > 20) return 'medium'; - return 'low'; - } - - ContactTelemetry copyWith({ - LatLng? gpsLocation, - double? batteryPercentage, - double? batteryMilliVolts, - double? temperature, - DateTime? timestamp, - double? humidity, - double? pressure, - Map? extraSensorData, - }) { - return ContactTelemetry( - gpsLocation: gpsLocation ?? this.gpsLocation, - batteryPercentage: batteryPercentage ?? this.batteryPercentage, - batteryMilliVolts: batteryMilliVolts ?? this.batteryMilliVolts, - temperature: temperature ?? this.temperature, - timestamp: timestamp ?? this.timestamp, - humidity: humidity ?? this.humidity, - pressure: pressure ?? this.pressure, - extraSensorData: extraSensorData ?? this.extraSensorData, - ); - } - - @override - String toString() { - return 'ContactTelemetry(gps: $gpsLocation, battery: $batteryPercentage%, temp: $temperatureΒ°C, time: $timestamp)'; - } -} +export 'package:meshcore_client/meshcore_client.dart' show ContactTelemetry; diff --git a/lib/models/message.dart b/lib/models/message.dart index 8250855..5fe1650 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -1,264 +1,40 @@ +export 'package:meshcore_client/meshcore_client.dart' + show + Message, + MessageType, + MessageTextType, + MessageDeliveryStatus, + MessageRecipient; + import 'package:flutter/foundation.dart'; -import 'package:latlong2/latlong.dart'; +import 'package:meshcore_client/meshcore_client.dart'; import 'sar_marker.dart'; -/// Message recipient tracking for grouped messages -class MessageRecipient { - final Uint8List publicKey; // Full public key - final String displayName; // Contact display name - final MessageDeliveryStatus deliveryStatus; - final int? expectedAckTag; - final int? roundTripTimeMs; - final DateTime? deliveredAt; - final DateTime sentAt; - - const MessageRecipient({ - required this.publicKey, - required this.displayName, - required this.deliveryStatus, - this.expectedAckTag, - this.roundTripTimeMs, - this.deliveredAt, - required this.sentAt, - }); - - MessageRecipient copyWith({ - Uint8List? publicKey, - String? displayName, - MessageDeliveryStatus? deliveryStatus, - int? expectedAckTag, - int? roundTripTimeMs, - DateTime? deliveredAt, - DateTime? sentAt, - }) { - return MessageRecipient( - publicKey: publicKey ?? this.publicKey, - displayName: displayName ?? this.displayName, - deliveryStatus: deliveryStatus ?? this.deliveryStatus, - expectedAckTag: expectedAckTag ?? this.expectedAckTag, - roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs, - deliveredAt: deliveredAt ?? this.deliveredAt, - sentAt: sentAt ?? this.sentAt, - ); - } - - String get publicKeyShort { - return publicKey - .sublist(0, publicKey.length < 6 ? publicKey.length : 6) - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(''); - } -} - -/// Message text types from MeshCore protocol -enum MessageTextType { - plain(0), - cliData(1), - signedPlain(2); - - const MessageTextType(this.value); - final int value; - - static MessageTextType fromValue(int value) { - return MessageTextType.values.firstWhere( - (e) => e.value == value, - orElse: () => MessageTextType.plain, - ); - } -} - -/// Message type (contact, channel, or system) -enum MessageType { - contact, - channel, - system, // System messages (log entries, status updates) -} - -/// Message delivery status -enum MessageDeliveryStatus { - sending, // Message is being sent - sent, // Message queued with expected ACK - delivered, // Delivery confirmed (ACK received) - failed, // Delivery failed - received, // Message received from another contact -} - -/// MeshCore message model -class Message { - final String id; - final MessageType messageType; - final Uint8List? senderPublicKeyPrefix; // 6 bytes for contact messages - final int? channelIdx; // For channel messages - final int pathLen; - final MessageTextType textType; - final int senderTimestamp; // Unix timestamp - final String text; - - // SAR marker data (if this is a SAR message) - final bool isSarMarker; - final LatLng? sarGpsCoordinates; - final String? sarNotes; // Optional message/notes for SAR marker - final String? sarCustomEmoji; // Custom emoji for unknown SAR marker types - final int? sarColorIndex; // Color index (0-7) from standard palette - - // Display metadata - final DateTime receivedAt; - final String? senderName; - - // Delivery tracking (for sent messages) - final MessageDeliveryStatus deliveryStatus; - final int? expectedAckTag; // Expected ACK/TAG from SENT response - final int? suggestedTimeoutMs; // Suggested timeout from SENT response - final int? roundTripTimeMs; // RTT from SEND_CONFIRMED - final DateTime? deliveredAt; // When delivery was confirmed - final Uint8List? - recipientPublicKey; // Full 32-byte public key of recipient (for retry) - - // Retry tracking (for automatic retry with progressive timeouts) - final int retryAttempt; // Current retry attempt (0-3), 0 = first send - final DateTime? lastRetryAt; // When last retry was sent - final bool - usedFloodFallback; // Whether message fell back to flood mode after retries - - // Read status tracking - final bool isRead; // Whether message has been read by user - - // Echo detection for public channel messages - final int echoCount; // Number of times message was detected being rebroadcast - final DateTime? firstEchoAt; // When first echo was detected - - // Drawing message tracking - final bool isDrawing; // Whether this message contains a map drawing - final String? drawingId; // ID of the associated drawing (for navigation) - - // Message grouping for bulk sends (same message to multiple recipients) - final String? groupId; // Shared ID for messages in the same bulk send - final List? - recipients; // List of recipients (for group leader message) - - Message({ - required this.id, - required this.messageType, - this.senderPublicKeyPrefix, - this.channelIdx, - required this.pathLen, - required this.textType, - required this.senderTimestamp, - required this.text, - this.isSarMarker = false, - this.sarGpsCoordinates, - this.sarNotes, - this.sarCustomEmoji, - this.sarColorIndex, - required this.receivedAt, - this.senderName, - this.deliveryStatus = MessageDeliveryStatus.received, - this.expectedAckTag, - this.suggestedTimeoutMs, - this.roundTripTimeMs, - this.deliveredAt, - this.recipientPublicKey, - this.retryAttempt = 0, - this.lastRetryAt, - this.usedFloodFallback = false, - this.isRead = false, - this.echoCount = 0, - this.firstEchoAt, - this.isDrawing = false, - this.drawingId, - this.groupId, - this.recipients, - }); - - /// Get SAR marker type by inferring from message content - /// Returns the type inferred from sarCustomEmoji or by parsing the message text +extension MessageSarExtension on Message { + /// Infer the [SarMarkerType] from stored SAR fields. + /// Returns null if this is not a SAR marker message. SarMarkerType? get sarMarkerType { if (!isSarMarker) return null; - // If we have a custom emoji stored, infer type from it if (sarCustomEmoji != null && sarCustomEmoji!.isNotEmpty) { return SarMarkerType.fromEmoji(sarCustomEmoji!); } - // Otherwise, parse the message text to extract the emoji final trimmed = text.trim(); if (!trimmed.startsWith('S:')) return null; - // Extract emoji from format: S::... or S:::... final parts = trimmed.split(':'); if (parts.length < 3) return null; - final emoji = parts[1]; - return SarMarkerType.fromEmoji(emoji); + return SarMarkerType.fromEmoji(parts[1]); } - /// Get sender public key as hex string - String? get senderKeyShort { - if (senderPublicKeyPrefix == null) return null; - return senderPublicKeyPrefix! - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(''); - } - - /// Get sender timestamp as DateTime - DateTime get sentAt { - return DateTime.fromMillisecondsSinceEpoch(senderTimestamp * 1000); - } - - /// Check if message is from a channel - bool get isChannelMessage => messageType == MessageType.channel; - - /// Check if message is from a contact - bool get isContactMessage => messageType == MessageType.contact; - - /// Check if message is a system message - bool get isSystemMessage => messageType == MessageType.system; - - /// Get friendly time since message was sent - String get timeAgo { - final diff = DateTime.now().difference(sentAt); - if (diff.inMinutes < 1) return 'Just now'; - if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; - if (diff.inHours < 24) return '${diff.inHours}h ago'; - return '${diff.inDays}d ago'; - } - - /// Get display name for sender (basic fallback without contact info) - String get displaySender { - if (senderName != null && senderName!.isNotEmpty) { - return senderName!; - } - if (senderKeyShort != null) { - return senderKeyShort!.substring(0, 8); - } - if (isChannelMessage && channelIdx != null) { - return 'Channel $channelIdx'; - } - return 'Unknown'; - } - - /// Get rich display name for sender using contact information - /// Returns emoji + display name if available, otherwise falls back to displaySender - String getRichDisplayName(dynamic contact) { - if (contact == null) return displaySender; - - // If contact has roleEmoji, use it with displayName - final roleEmoji = contact.roleEmoji; - if (roleEmoji != null && roleEmoji.isNotEmpty) { - return '$roleEmoji ${contact.displayName}'; - } - - // Otherwise just use advName or displayName - return contact.displayName ?? contact.advName ?? displaySender; - } - - /// Convert to SAR marker if applicable + /// Convert to a [SarMarker] if this message contains SAR data. SarMarker? toSarMarker() { if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) { return null; } - // Debug: Check what's in sarNotes debugPrint('πŸ“ [Message.toSarMarker] Converting to marker:'); debugPrint(' message.text: "$text"'); debugPrint(' message.sarNotes: "$sarNotes"'); @@ -272,220 +48,9 @@ class Message { timestamp: sentAt, senderPublicKey: senderPublicKeyPrefix, senderName: senderName, - notes: sarNotes, // Use dedicated notes field instead of full text - customEmoji: sarCustomEmoji, // Preserve custom emoji for unknown types - colorIndex: sarColorIndex, // Pass through color index + notes: sarNotes, + customEmoji: sarCustomEmoji, + colorIndex: sarColorIndex, ); } - - /// Get echo status text for channel messages - String get echoStatusText { - if (!isChannelMessage) return ''; - - if (echoCount == 0) { - return 'Broadcast (no echoes)'; - } else if (echoCount == 1) { - return 'Rebroadcast by 1 node'; - } else { - return 'Rebroadcast by $echoCount nodes'; - } - } - - /// Get friendly delivery status description - String get deliveryStatusText { - // For channel messages, show echo status instead - if (isChannelMessage && isSentMessage) { - return echoStatusText; - } - - switch (deliveryStatus) { - case MessageDeliveryStatus.sending: - if (retryAttempt > 0) { - return 'Retrying ($retryAttempt/3)...'; - } - return 'Sending...'; - - case MessageDeliveryStatus.sent: - if (retryAttempt > 0) { - return 'Sent (retry $retryAttempt)'; - } - return 'Sent'; - - case MessageDeliveryStatus.delivered: - final rttText = roundTripTimeMs != null ? '${roundTripTimeMs}ms' : ''; - if (retryAttempt > 0 && rttText.isNotEmpty) { - return 'Delivered ($rttText) [retry $retryAttempt]'; - } else if (retryAttempt > 0) { - return 'Delivered [retry $retryAttempt]'; - } else if (rttText.isNotEmpty) { - return 'Delivered ($rttText)'; - } - return 'Delivered'; - - case MessageDeliveryStatus.failed: - if (usedFloodFallback) { - return 'Failed (tried flood)'; - } - if (retryAttempt > 0) { - final retryWord = retryAttempt == 1 ? 'retry' : 'retries'; - return 'Failed (after $retryAttempt $retryWord)'; - } - return 'Failed'; - - case MessageDeliveryStatus.received: - return ''; - } - } - - /// Check if this is a sent message (not received) - bool get isSentMessage => deliveryStatus != MessageDeliveryStatus.received; - - /// Check if this message is from self (own message) - /// [selfPublicKey] - the device's own public key (first 6 bytes) - bool isFromSelf(Uint8List? selfPublicKey) { - if (selfPublicKey == null || selfPublicKey.length < 6) return false; - - // Compare sender public key prefix with self public key prefix - if (senderPublicKeyPrefix != null && senderPublicKeyPrefix!.length >= 6) { - return senderPublicKeyPrefix![0] == selfPublicKey[0] && - senderPublicKeyPrefix![1] == selfPublicKey[1] && - senderPublicKeyPrefix![2] == selfPublicKey[2] && - senderPublicKeyPrefix![3] == selfPublicKey[3] && - senderPublicKeyPrefix![4] == selfPublicKey[4] && - senderPublicKeyPrefix![5] == selfPublicKey[5]; - } - - return false; - } - - /// Get drawing metadata from message text (returns null if not a drawing) - /// Extracts basic info for display in message bubbles - Map? get drawingMetadata { - if (!isDrawing || !text.startsWith('D:')) return null; - - try { - // Return basic metadata (actual parsing happens in DrawingMessageParser) - return {'hasDrawing': true, 'drawingId': drawingId}; - } catch (e) { - return null; - } - } - - Message copyWith({ - String? id, - MessageType? messageType, - Uint8List? senderPublicKeyPrefix, - int? channelIdx, - int? pathLen, - MessageTextType? textType, - int? senderTimestamp, - String? text, - bool? isSarMarker, - LatLng? sarGpsCoordinates, - String? sarNotes, - String? sarCustomEmoji, - int? sarColorIndex, - DateTime? receivedAt, - String? senderName, - MessageDeliveryStatus? deliveryStatus, - int? expectedAckTag, - int? suggestedTimeoutMs, - int? roundTripTimeMs, - DateTime? deliveredAt, - Uint8List? recipientPublicKey, - int? retryAttempt, - DateTime? lastRetryAt, - bool? usedFloodFallback, - bool? isRead, - int? echoCount, - DateTime? firstEchoAt, - bool? isDrawing, - String? drawingId, - String? groupId, - List? recipients, - }) { - return Message( - id: id ?? this.id, - messageType: messageType ?? this.messageType, - senderPublicKeyPrefix: - senderPublicKeyPrefix ?? this.senderPublicKeyPrefix, - channelIdx: channelIdx ?? this.channelIdx, - pathLen: pathLen ?? this.pathLen, - textType: textType ?? this.textType, - senderTimestamp: senderTimestamp ?? this.senderTimestamp, - text: text ?? this.text, - isSarMarker: isSarMarker ?? this.isSarMarker, - sarGpsCoordinates: sarGpsCoordinates ?? this.sarGpsCoordinates, - sarNotes: sarNotes ?? this.sarNotes, - sarCustomEmoji: sarCustomEmoji ?? this.sarCustomEmoji, - sarColorIndex: sarColorIndex ?? this.sarColorIndex, - receivedAt: receivedAt ?? this.receivedAt, - senderName: senderName ?? this.senderName, - deliveryStatus: deliveryStatus ?? this.deliveryStatus, - expectedAckTag: expectedAckTag ?? this.expectedAckTag, - suggestedTimeoutMs: suggestedTimeoutMs ?? this.suggestedTimeoutMs, - roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs, - deliveredAt: deliveredAt ?? this.deliveredAt, - recipientPublicKey: recipientPublicKey ?? this.recipientPublicKey, - retryAttempt: retryAttempt ?? this.retryAttempt, - lastRetryAt: lastRetryAt ?? this.lastRetryAt, - usedFloodFallback: usedFloodFallback ?? this.usedFloodFallback, - isRead: isRead ?? this.isRead, - echoCount: echoCount ?? this.echoCount, - firstEchoAt: firstEchoAt ?? this.firstEchoAt, - isDrawing: isDrawing ?? this.isDrawing, - drawingId: drawingId ?? this.drawingId, - groupId: groupId ?? this.groupId, - recipients: recipients ?? this.recipients, - ); - } - - /// Check if this is a grouped message (sent to multiple recipients) - bool get isGroupedMessage => - groupId != null && recipients != null && recipients!.isNotEmpty; - - /// Get count of recipients who have received/delivered the message - int get deliveredRecipientsCount { - if (recipients == null) return 0; - return recipients! - .where((r) => r.deliveryStatus == MessageDeliveryStatus.delivered) - .length; - } - - /// Get count of recipients who are pending (sending/sent) - int get pendingRecipientsCount { - if (recipients == null) return 0; - return recipients! - .where( - (r) => - r.deliveryStatus == MessageDeliveryStatus.sending || - r.deliveryStatus == MessageDeliveryStatus.sent, - ) - .length; - } - - /// Get count of recipients who failed to receive - int get failedRecipientsCount { - if (recipients == null) return 0; - return recipients! - .where((r) => r.deliveryStatus == MessageDeliveryStatus.failed) - .length; - } - - @override - String toString() { - if (isSarMarker) { - return 'Message(SAR: ${sarMarkerType?.displayName}, from: $displaySender)'; - } - return 'Message(from: $displaySender, text: ${text.length > 30 ? '${text.substring(0, 30)}...' : text})'; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is Message && id == other.id; - } - - @override - int get hashCode => id.hashCode; } diff --git a/lib/models/sent_message_tracker.dart b/lib/models/sent_message_tracker.dart index bac614d..1d38868 100644 --- a/lib/models/sent_message_tracker.dart +++ b/lib/models/sent_message_tracker.dart @@ -1,83 +1 @@ -import 'dart:typed_data'; - -/// Tracks sent public channel messages for echo detection -/// -/// When a message is sent to the public channel, it's encrypted with AES128-ECB -/// which is deterministic. When another node receives and rebroadcasts it, -/// the raw packet will be byte-for-byte identical. We can detect these echoes -/// by comparing the raw packet data from PUSH_CODE_LOG_RX_DATA (0x88) against -/// packets we've sent. -class SentMessageTracker { - /// Unique identifier for the message (timestamp-based) - final String messageId; - - /// SHA256 hash of the encrypted packet for fast O(1) lookup - final String packetHashHex; - - /// Original raw encrypted packet bytes (for verification) - final Uint8List? rawPacket; - - /// When the message was sent - final DateTime sentTime; - - /// When this tracker expires (default: 5 minutes) - final DateTime expiryTime; - - /// Number of times we've detected this message being rebroadcast - int echoCount; - - /// Unique echo paths detected (SNR/RSSI signatures) - /// Format: "snr_rssi" e.g., "20_-56" means SNR=5.0dB (20/4), RSSI=-56dBm - final Set uniqueEchoPaths; - - /// Timestamps when echoes were detected - final List echoTimestamps; - - SentMessageTracker({ - required this.messageId, - required this.packetHashHex, - this.rawPacket, - required this.sentTime, - required this.expiryTime, - this.echoCount = 0, - Set? uniqueEchoPaths, - List? echoTimestamps, - }) : uniqueEchoPaths = uniqueEchoPaths ?? {}, - echoTimestamps = echoTimestamps ?? []; - - /// Check if this tracker has expired - bool get isExpired => DateTime.now().isAfter(expiryTime); - - /// Time until expiry - Duration get timeUntilExpiry => expiryTime.difference(DateTime.now()); - - /// Add an echo detection - void addEcho(int snrRaw, int rssiDbm) { - echoCount++; - uniqueEchoPaths.add('${snrRaw}_$rssiDbm'); - echoTimestamps.add(DateTime.now()); - } - - /// Get the SNR in dB from raw value - static double snrRawToDb(int snrRaw) { - return snrRaw.toSigned(8) / 4.0; - } - - /// Get formatted echo statistics - String get echoStats { - if (echoCount == 0) return 'No echoes detected'; - if (echoCount == 1) return '1 echo from ${uniqueEchoPaths.length} path(s)'; - return '$echoCount echoes from ${uniqueEchoPaths.length} path(s)'; - } - - /// Get average time to first echo - Duration? get timeToFirstEcho { - if (echoTimestamps.isEmpty) return null; - return echoTimestamps.first.difference(sentTime); - } - - @override - String toString() { - return 'SentMessageTracker(id=$messageId, echoes=$echoCount, paths=${uniqueEchoPaths.length}, expired=$isExpired)'; - } -} +export 'package:meshcore_client/meshcore_client.dart' show SentMessageTracker; diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 8820fd5..df86158 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -4,12 +4,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:crypto/crypto.dart'; import '../models/device_info.dart'; -import '../models/contact.dart'; -import '../models/message.dart'; import '../models/room_login_state.dart'; import '../models/sse_server_config.dart'; -import '../services/meshcore_ble_service.dart'; -import '../services/meshcore_constants.dart'; +import 'package:meshcore_client/meshcore_client.dart'; import '../services/sse_server_service.dart'; import '../services/sse_client_service.dart'; import '../utils/sar_message_parser.dart'; diff --git a/lib/screens/packet_log_screen.dart b/lib/screens/packet_log_screen.dart index 63ad4dd..4b7c318 100644 --- a/lib/screens/packet_log_screen.dart +++ b/lib/screens/packet_log_screen.dart @@ -3,8 +3,7 @@ import 'package:flutter/services.dart'; import 'package:share_plus/share_plus.dart'; import 'dart:io'; import 'package:path_provider/path_provider.dart'; -import '../models/ble_packet_log.dart'; -import '../services/meshcore_ble_service.dart'; +import 'package:meshcore_client/meshcore_client.dart'; import '../l10n/app_localizations.dart'; class PacketLogScreen extends StatefulWidget { diff --git a/lib/services/background_location_service.dart b/lib/services/background_location_service.dart index cb770c5..c93dcd5 100644 --- a/lib/services/background_location_service.dart +++ b/lib/services/background_location_service.dart @@ -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 diff --git a/lib/services/ble/ble_command_queue.dart b/lib/services/ble/ble_command_queue.dart deleted file mode 100644 index ecc4cfb..0000000 --- a/lib/services/ble/ble_command_queue.dart +++ /dev/null @@ -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 { - /// 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 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 _queue = []; - - // Mutex lock using Completer - Completer _lock = Completer()..complete(); - - // Whether queue is currently processing - bool _isProcessing = false; - - // Pending responses mapped by command code - final Map _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 enqueue({ - 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( - data: data, - commandCode: commandCode, - responseType: responseType, - expectedResponseCode: expectedResponseCode, - completer: Completer(), - 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 _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(); - - // 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(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(); - } - } -} diff --git a/lib/services/ble/ble_command_sender.dart b/lib/services/ble/ble_command_sender.dart deleted file mode 100644 index 1fbf78f..0000000 --- a/lib/services/ble/ble_command_sender.dart +++ /dev/null @@ -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 _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 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 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( - 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 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( - 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 writeDataAndWaitForResponse( - 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( - 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 _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(); - } -} diff --git a/lib/services/ble/ble_connection_manager.dart b/lib/services/ble/ble_connection_manager.dart deleted file mode 100644 index 55cd626..0000000 --- a/lib/services/ble/ble_connection_manager.dart +++ /dev/null @@ -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? _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 _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 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 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 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 _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; - } -} diff --git a/lib/services/ble/ble_response_handler.dart b/lib/services/ble/ble_response_handler.dart deleted file mode 100644 index b3f33ab..0000000 --- a/lib/services/ble/ble_response_handler.dart +++ /dev/null @@ -1,1245 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -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 '../../models/sent_message_tracker.dart'; -import '../buffer_reader.dart'; -import '../meshcore_constants.dart'; -import '../meshcore_opcode_names.dart'; -import '../protocol/frame_parser.dart'; -import 'ble_command_queue.dart'; - -/// Callback types for response events -typedef OnContactCallback = void Function(Contact contact); -typedef OnContactsCompleteCallback = void Function(List contacts); -typedef OnMessageCallback = void Function(Message message); -typedef OnTelemetryCallback = - void Function(Uint8List publicKey, Uint8List lppData); -typedef OnSelfInfoCallback = void Function(Map selfInfo); -typedef OnDeviceInfoCallback = void Function(Map 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 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 OnMessageEchoDetectedCallback = - void Function(String messageId, int echoCount, int snrRaw, int rssiDbm); - -/// Processes incoming responses from the BLE device -class BleResponseHandler { - StreamSubscription? _txSubscription; - final List _pendingContacts = []; - int _rxPacketCount = 0; - final List _packetLogs = []; - static const int _maxLogSize = 1000; - - // Reference to command queue for completing pending commands - BleCommandQueue? _commandQueue; - - // Echo detection for public channel messages - final Map _sentMessageTrackers = {}; - static const int _maxTrackers = 100; - static const Duration _trackerTTL = Duration(minutes: 5); - - // Callbacks - 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; - OnStatusResponseCallback? onStatusResponse; - OnBinaryResponseCallback? onBinaryResponse; - OnBatteryAndStorageCallback? onBatteryAndStorage; - OnErrorCallback? onError; - OnContactNotFoundCallback? onContactNotFound; - OnChannelInfoCallback? onChannelInfoReceived; - OnMessageEchoDetectedCallback? onMessageEchoDetected; - VoidCallback? onRxActivity; - void Function(Uint8List publicKey)? onContactDeleted; - VoidCallback? onContactsFull; - - // Track the last command that was sent, so we can retry if it fails with ERR_CODE_NOT_FOUND - Uint8List? _lastContactPublicKey; - - // Getters - int get rxPacketCount => _rxPacketCount; - List get packetLogs => List.unmodifiable(_packetLogs); - - /// Set the command queue for completing pending commands - void setCommandQueue(BleCommandQueue? queue) { - _commandQueue = queue; - } - - /// Subscribe to TX characteristic notifications - void subscribeToNotifications(BluetoothCharacteristic txCharacteristic) { - _txSubscription = txCharacteristic.lastValueStream.listen( - _onDataReceived, - onError: (error) { - debugPrint('❌ [BLE] TX notification error: $error'); - onError?.call('TX notification error: $error'); - }, - ); - } - - /// Handle incoming data from TX characteristic - void _onDataReceived(List data) { - try { - // Handle empty data - if (data.isEmpty) { - debugPrint('⚠️ [RX] Empty data received, ignoring'); - return; - } - - final dataBytes = Uint8List.fromList(data); - - // Increment RX packet counter and trigger activity indicator - _rxPacketCount++; - onRxActivity?.call(); - - final reader = BufferReader(dataBytes); - final responseCode = reader.readByte(); - - // Get opcode name for logging - final opcodeName = MeshCoreOpcodeNames.getOpcodeName( - responseCode, - isTx: false, - ); - final opcodeHex = - '0x${responseCode.toRadixString(16).padLeft(2, '0').toUpperCase()}'; - - debugPrint('πŸ“₯ [RX] Received: $opcodeName ($opcodeHex)'); - debugPrint(' Data size: ${data.length} bytes'); - debugPrint( - ' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}', - ); - debugPrint(' Payload: ${reader.remainingBytesCount} bytes'); - - // Log RX packet (before processing so we capture everything) - _logPacket(dataBytes, PacketDirection.rx, responseCode: responseCode); - - switch (responseCode) { - case MeshCoreConstants.respContactsStart: - debugPrint(' β†’ Handling ContactsStart'); - _handleContactsStart(reader); - break; - case MeshCoreConstants.respContact: - debugPrint(' β†’ Handling Contact'); - _handleContact(reader); - break; - case MeshCoreConstants.respEndOfContacts: - debugPrint(' β†’ Handling EndOfContacts'); - _handleEndOfContacts(reader); - break; - case MeshCoreConstants.respSent: - debugPrint(' β†’ Handling Sent confirmation'); - _handleSentConfirmation(reader); - break; - case MeshCoreConstants.respContactMsgRecv: - debugPrint(' β†’ Handling ContactMessage'); - _handleContactMessage(reader); - break; - case MeshCoreConstants.respChannelMsgRecv: - debugPrint(' β†’ Handling ChannelMessage'); - _handleChannelMessage(reader); - break; - case MeshCoreConstants.respContactMsgRecvV3: - debugPrint(' β†’ Handling ContactMessage V3'); - _handleContactMessageV3(reader); - break; - case MeshCoreConstants.respChannelMsgRecvV3: - debugPrint(' β†’ Handling ChannelMessage V3'); - _handleChannelMessageV3(reader); - break; - case MeshCoreConstants.pushTelemetryResponse: - debugPrint(' β†’ Handling TelemetryResponse'); - _handleTelemetryResponse(reader); - break; - case MeshCoreConstants.pushBinaryResponse: - debugPrint(' β†’ Handling BinaryResponse'); - _handleBinaryResponse(reader); - break; - case MeshCoreConstants.respDeviceInfo: - debugPrint(' β†’ Handling DeviceInfo'); - _handleDeviceInfo(reader); - break; - case MeshCoreConstants.respSelfInfo: - debugPrint(' β†’ Handling SelfInfo'); - _handleSelfInfo(reader); - break; - case MeshCoreConstants.pushAdvert: - debugPrint(' β†’ Handling Advert push'); - _handleAdvert(reader); - break; - case MeshCoreConstants.pushPathUpdated: - debugPrint(' β†’ Handling PathUpdated push'); - _handlePathUpdated(reader); - break; - case MeshCoreConstants.pushLogRxData: - debugPrint(' β†’ Handling LogRxData push'); - _handleLogRxData(reader); - break; - case MeshCoreConstants.pushNewAdvert: - debugPrint(' β†’ Handling NewAdvert push'); - _handleNewAdvert(reader); - break; - case MeshCoreConstants.pushSendConfirmed: - debugPrint(' β†’ Handling SendConfirmed push'); - _handleSendConfirmed(reader); - break; - case MeshCoreConstants.pushMsgWaiting: - debugPrint(' β†’ Handling MsgWaiting push'); - _handleMsgWaiting(reader); - break; - case MeshCoreConstants.pushLoginSuccess: - debugPrint(' β†’ Handling LoginSuccess push'); - _handleLoginSuccess(reader); - break; - case MeshCoreConstants.pushLoginFail: - debugPrint(' β†’ Handling LoginFail push'); - _handleLoginFail(reader); - break; - case MeshCoreConstants.pushStatusResponse: - debugPrint(' β†’ Handling StatusResponse push'); - _handleStatusResponse(reader); - break; - case MeshCoreConstants.respCurrTime: - debugPrint(' β†’ Handling CurrentTime'); - _handleCurrentTime(reader); - break; - case MeshCoreConstants.respBatteryVoltage: - debugPrint(' β†’ Handling BatteryAndStorage'); - _handleBatteryAndStorage(reader); - break; - case MeshCoreConstants.respChannelInfo: - debugPrint(' β†’ Handling ChannelInfo'); - _handleChannelInfo(reader); - break; - case MeshCoreConstants.respNoMoreMessages: - debugPrint(' β†’ Response: No More Messages'); - onNoMoreMessages?.call(); - break; - case MeshCoreConstants.pushPathDiscoveryResponse: - debugPrint(' β†’ Path discovery response (not yet handled)'); - break; - case MeshCoreConstants.pushControlData: - debugPrint(' β†’ Control data push (not yet handled)'); - break; - case MeshCoreConstants.pushContactDeleted: - debugPrint(' β†’ Handling ContactDeleted push'); - _handleContactDeleted(reader); - break; - case MeshCoreConstants.pushContactsFull: - debugPrint(' β†’ Contacts storage full'); - onContactsFull?.call(); - break; - case MeshCoreConstants.respOk: - debugPrint(' β†’ Response: OK'); - // Complete any pending ACK command - _commandQueue?.completeCommand(MeshCoreConstants.respOk, null); - break; - case MeshCoreConstants.respErr: - debugPrint(' β†’ Response: ERROR'); - _handleError(reader); - break; - default: - debugPrint(' ⚠️ Unknown response code: $responseCode'); - break; - } - debugPrint('βœ… [BLE] Data parsed successfully'); - } catch (e, stackTrace) { - debugPrint('❌ [BLE] Data parsing error: $e'); - debugPrint(' Stack trace: $stackTrace'); - onError?.call('Data parsing error: $e'); - } - } - - /// Handle ContactsStart response - void _handleContactsStart(BufferReader reader) { - _pendingContacts.clear(); - FrameParser.parseContactsStart(reader); - } - - /// Handle Contact response - void _handleContact(BufferReader reader) { - try { - final contact = FrameParser.parseContact(reader); - debugPrint(' βœ… [Contact] Parsed successfully: ${contact.advName}'); - debugPrint( - ' outPathLen: ${contact.outPathLen} (${contact.pathDescription})', - ); - _pendingContacts.add(contact); - onContactReceived?.call(contact); - } catch (e) { - debugPrint(' ❌ [Contact] Parsing error: $e'); - onError?.call('Contact parsing error: $e'); - } - } - - /// Handle EndOfContacts response - void _handleEndOfContacts(BufferReader reader) { - onContactsComplete?.call(List.from(_pendingContacts)); - _pendingContacts.clear(); - } - - /// Handle Sent confirmation response - void _handleSentConfirmation(BufferReader reader) { - try { - final result = FrameParser.parseSentConfirmation(reader); - if (result.isNotEmpty) { - debugPrint(' βœ… [Sent] Message sent successfully'); - - // Complete any pending command waiting for sent confirmation - _commandQueue?.completeCommand>( - MeshCoreConstants.respSent, - result, - ); - - onMessageSent?.call( - result['expectedAckTag'] as int, - result['suggestedTimeout'] as int, - result['isFloodMode'] as bool, - _lastContactPublicKey, - ); - } - } catch (e) { - debugPrint(' ❌ [Sent] Parsing error: $e'); - } - } - - /// Handle ContactMessage response - void _handleContactMessage(BufferReader reader) { - try { - final message = FrameParser.parseContactMessage(reader); - debugPrint(' βœ… [ContactMessage] Parsed successfully'); - onMessageReceived?.call(message); - } catch (e) { - debugPrint(' ❌ [ContactMessage] Parsing error: $e'); - onError?.call('Contact message parsing error: $e'); - } - } - - /// Handle ChannelMessage response - void _handleChannelMessage(BufferReader reader) { - try { - final message = FrameParser.parseChannelMessage(reader); - debugPrint(' βœ… [ChannelMessage] Parsed successfully'); - onMessageReceived?.call(message); - } catch (e) { - debugPrint(' ❌ [ChannelMessage] Parsing error: $e'); - onError?.call('Channel message parsing error: $e'); - } - } - - /// Handle ContactMessage V3 response (firmware ver >= 3, has SNR header) - void _handleContactMessageV3(BufferReader reader) { - try { - final message = FrameParser.parseContactMessageV3(reader); - debugPrint(' βœ… [ContactMessage V3] Parsed successfully'); - onMessageReceived?.call(message); - } catch (e) { - debugPrint(' ❌ [ContactMessage V3] Parsing error: $e'); - onError?.call('Contact message V3 parsing error: $e'); - } - } - - /// Handle ChannelMessage V3 response (firmware ver >= 3, has SNR header) - void _handleChannelMessageV3(BufferReader reader) { - try { - final message = FrameParser.parseChannelMessageV3(reader); - debugPrint(' βœ… [ChannelMessage V3] Parsed successfully'); - onMessageReceived?.call(message); - } catch (e) { - debugPrint(' ❌ [ChannelMessage V3] Parsing error: $e'); - onError?.call('Channel message V3 parsing error: $e'); - } - } - - /// Handle ContactDeleted push (0x8F) β€” contact overwritten due to contacts full - void _handleContactDeleted(BufferReader reader) { - try { - if (reader.remainingBytesCount >= 32) { - final publicKey = reader.readBytes(32); - debugPrint(' βœ… [ContactDeleted] Contact removed by firmware'); - onContactDeleted?.call(Uint8List.fromList(publicKey)); - } - } catch (e) { - debugPrint(' ❌ [ContactDeleted] Parsing error: $e'); - } - } - - /// Handle TelemetryResponse push - void _handleTelemetryResponse(BufferReader reader) { - try { - final result = FrameParser.parseTelemetryResponse(reader); - debugPrint(' βœ… [Telemetry] Parsed successfully'); - onTelemetryReceived?.call( - result['publicKeyPrefix'] as Uint8List, - result['lppSensorData'] as Uint8List, - ); - } catch (e) { - debugPrint(' ❌ [Telemetry] Parsing error: $e'); - onError?.call('Telemetry parsing error: $e'); - } - } - - /// Handle BinaryResponse push - void _handleBinaryResponse(BufferReader reader) { - try { - final result = FrameParser.parseBinaryResponse(reader); - debugPrint(' βœ… [BinaryResponse] Parsed successfully'); - onBinaryResponse?.call( - result['publicKeyPrefix'] as Uint8List, - result['tag'] as int, - result['responseData'] as Uint8List, - ); - } catch (e) { - debugPrint(' ❌ [BinaryResponse] Parsing error: $e'); - onError?.call('Binary response parsing error: $e'); - } - } - - /// Handle DeviceInfo response - void _handleDeviceInfo(BufferReader reader) { - try { - final info = FrameParser.parseDeviceInfo(reader); - - // Complete any pending command waiting for device info - _commandQueue?.completeCommand>( - MeshCoreConstants.respDeviceInfo, - info, - ); - - onDeviceInfoReceived?.call(info); - debugPrint(' βœ… [DeviceInfo] Parsed successfully'); - } catch (e) { - debugPrint(' ❌ [DeviceInfo] Parsing error: $e'); - onError?.call('DeviceInfo parsing error: $e'); - } - } - - /// Handle SelfInfo response - void _handleSelfInfo(BufferReader reader) { - try { - final info = FrameParser.parseSelfInfo(reader); - - // Complete any pending command waiting for self info - if (info.isNotEmpty) { - _commandQueue?.completeCommand>( - MeshCoreConstants.respSelfInfo, - info, - ); - onSelfInfoReceived?.call(info); - } - - debugPrint(' βœ… [SelfInfo] Parsed successfully'); - } catch (e) { - debugPrint(' ❌ [SelfInfo] Parsing error: $e'); - } - } - - /// Handle Advert push (0x80) - basic advertisement with public key only - void _handleAdvert(BufferReader reader) { - try { - final publicKey = FrameParser.parseAdvert(reader); - if (publicKey != null) { - final shortKey = publicKey - .sublist(0, 8) - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(''); - debugPrint( - ' βœ… [Advert 0x80] From node: $shortKey... (public key only, no location data)', - ); - onAdvertReceived?.call(publicKey); - } - debugPrint(' βœ… [Advert] Parsed successfully'); - } catch (e) { - debugPrint(' ❌ [Advert] Parsing error: $e'); - } - } - - /// Handle PathUpdated push - void _handlePathUpdated(BufferReader reader) { - try { - final publicKey = FrameParser.parsePathUpdated(reader); - if (publicKey != null) { - onPathUpdated?.call(publicKey); - } - debugPrint(' βœ… [PathUpdated] Parsed successfully'); - } catch (e) { - debugPrint(' ❌ [PathUpdated] Parsing error: $e'); - } - } - - /// Handle LogRxData push - includes extensive decoding logic - void _handleLogRxData(BufferReader reader) { - try { - debugPrint( - ' [LogRxData] Parsing log rx data from over-the-air packet...', - ); - final data = reader.readRemainingBytes(); - - if (data.length < 2) { - debugPrint(' ⚠️ [LogRxData] Insufficient data'); - return; - } - - final snrRaw = data[0]; - final snrDb = (snrRaw.toSigned(8)) / 4.0; - debugPrint(' SNR: ${snrDb.toStringAsFixed(2)} dB'); - - final rssiDbm = data[1].toSigned(8); - debugPrint(' RSSI: $rssiDbm dBm'); - - if (data.length <= 2) { - debugPrint(' ⚠️ [LogRxData] No raw packet data'); - return; - } - - final rawPacketData = data.sublist(2); - debugPrint(' Raw packet data: ${rawPacketData.length} bytes'); - - // Decode packet header and path for display - if (rawPacketData.length >= 2) { - final header = rawPacketData[0]; - final payloadType = (header >> 2) & 0x0F; - final pathLen = rawPacketData[1]; - - debugPrint( - ' Packet type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}', - ); - - if (pathLen > 0 && rawPacketData.length >= 2 + pathLen) { - final path = rawPacketData.sublist(2, 2 + pathLen); - final pathStr = path - .map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}') - .join(' β†’ '); - debugPrint(' Path ($pathLen hops): $pathStr'); - - // Highlight multi-hop packets - if (pathLen > 1) { - debugPrint( - ' πŸ”„ MULTI-HOP PACKET! Original sender: 0x${path[0].toRadixString(16).padLeft(2, '0')}', - ); - } - - // Check if our node hash is in the path - if (_ourNodeHash != null && path.contains(_ourNodeHash!)) { - debugPrint( - ' βœ…βœ…βœ… ECHO DETECTED! Path contains our hash (0x${_ourNodeHash!.toRadixString(16).padLeft(2, '0')}) βœ…βœ…βœ…', - ); - if (path[0] == _ourNodeHash) { - debugPrint(' πŸ‘‰ WE are the original sender!'); - } else { - debugPrint( - ' πŸ‘‰ Original sender: 0x${path[0].toRadixString(16).padLeft(2, '0')}, WE sent it to the network', - ); - } - } else { - debugPrint(' ℹ️ Does NOT contain our hash (not our message)'); - } - } else { - debugPrint(' Path length: $pathLen'); - } - } - - // Calculate entropy - final uniqueBytes = rawPacketData.toSet().length; - final entropy = uniqueBytes / rawPacketData.length; - final isLikelyEncrypted = entropy > 0.7; - - // First, try to associate this packet with a recently sent message (within 2s) - _associatePacketWithSentMessage(rawPacketData); - - // Then, check if this packet matches any sent message (echo detection) - _checkForEcho(rawPacketData, snrRaw, rssiDbm); - - // Create decoded info for packet log (includes SNR and RSSI) - final logRxDataInfo = LogRxDataInfo( - entropy: entropy, - isLikelyEncrypted: isLikelyEncrypted, - snrDb: snrDb, - rssiDbm: rssiDbm, - ); - - // Update the most recent packet log entry - if (_packetLogs.isNotEmpty) { - final lastLog = _packetLogs.last; - if (lastLog.responseCode == MeshCoreConstants.pushLogRxData) { - _packetLogs[_packetLogs.length - 1] = BlePacketLog( - timestamp: lastLog.timestamp, - rawData: lastLog.rawData, - direction: lastLog.direction, - responseCode: lastLog.responseCode, - description: lastLog.description, - logRxDataInfo: logRxDataInfo, - ); - } - } - - debugPrint(' βœ… [LogRxData] Parsed successfully'); - } catch (e) { - debugPrint(' ❌ [LogRxData] Parsing error: $e'); - } - } - - /// Simple hash function for packet identification (replaces SHA256) - String _simplePacketHash(Uint8List packet) { - // Use a simple hash based on packet length and first/last bytes - // This is sufficient for short-lived echo detection (5 min TTL) - if (packet.isEmpty) return '0'; - - int hash = packet.length; - // Mix in bytes from start, middle, and end - for (int i = 0; i < packet.length && i < 8; i++) { - hash = ((hash << 5) - hash) + packet[i]; - hash = hash & 0xFFFFFFFF; // Keep 32-bit - } - if (packet.length > 16) { - for ( - int i = packet.length ~/ 2; - i < packet.length ~/ 2 + 8 && i < packet.length; - i++ - ) { - hash = ((hash << 5) - hash) + packet[i]; - hash = hash & 0xFFFFFFFF; - } - } - if (packet.length > 8) { - for (int i = packet.length - 8; i < packet.length; i++) { - hash = ((hash << 5) - hash) + packet[i]; - hash = hash & 0xFFFFFFFF; - } - } - return hash.toRadixString(16).padLeft(8, '0'); - } - - /// Check if received packet is an echo of a sent message - void _checkForEcho(Uint8List rawPacket, int snrRaw, int rssiDbm) { - try { - debugPrint( - ' πŸ” [Echo] _checkForEcho called, packet size: ${rawPacket.length} bytes', - ); - - // Need at least header + path_len - if (rawPacket.length < 2) { - debugPrint(' ⚠️ [Echo] Packet too short'); - return; - } - - final header = rawPacket[0]; - final payloadType = (header >> 2) & 0x0F; - debugPrint( - ' πŸ” [Echo] Payload type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}', - ); - if (payloadType != 0x05) { - debugPrint(' ⚠️ [Echo] Not GRP_TXT, ignoring'); - return; // Only track GRP_TXT - } - - final pathLen = rawPacket[1]; - debugPrint(' πŸ” [Echo] Path length: $pathLen'); - if (pathLen == 0 || rawPacket.length < 2 + pathLen) { - debugPrint(' ⚠️ [Echo] Invalid path length'); - return; - } - - // Extract path for unique echo tracking - final path = rawPacket.sublist(2, 2 + pathLen); - final pathSignature = path - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(':'); - - // Check if our node hash is in the path (meaning this is our message being rebroadcast) - final containsOurHash = - _ourNodeHash != null && path.contains(_ourNodeHash!); - if (!containsOurHash) { - // This packet doesn't have our hash in the path, so it's not our message - return; - } - - // Extract encrypted payload - final payloadStart = 2 + pathLen; - final encryptedPayload = rawPacket.sublist(payloadStart); - final payloadHash = _simplePacketHash(encryptedPayload); - - // Check if we have a matching sent message (by payload hash) - final tracker = _sentMessageTrackers[payloadHash]; - if (tracker != null && !tracker.isExpired) { - // Check if this is a NEW path (different from already seen paths) - if (!tracker.uniqueEchoPaths.contains(pathSignature)) { - // New echo detected via different path! - tracker.uniqueEchoPaths.add(pathSignature); - tracker.echoCount++; - tracker.echoTimestamps.add(DateTime.now()); - - debugPrint(' πŸ”Š [Echo] New echo detected!'); - debugPrint(' Message: ${tracker.messageId}'); - debugPrint(' Path: $pathSignature'); - debugPrint(' Total echoes: ${tracker.echoCount}'); - debugPrint(' Unique paths: ${tracker.uniqueEchoPaths.length}'); - - // Notify callback - onMessageEchoDetected?.call( - tracker.messageId, - tracker.echoCount, - snrRaw, - rssiDbm, - ); - } else { - debugPrint( - ' ♻️ [Echo] Duplicate path (already counted): $pathSignature', - ); - } - } - - // Cleanup expired trackers - _cleanupExpiredTrackers(); - } catch (e) { - debugPrint(' ⚠️ [Echo] Error checking for echo: $e'); - } - } - - /// Track a sent public channel message for echo detection - /// - /// NEW STRATEGY: Since firmware doesn't log our own transmissions, - /// we track ANY GRP_TXT packets that arrive shortly after sending. - /// The first packet with matching encrypted payload is likely our message, - /// and subsequent packets with the same payload are echoes. - void trackSentMessage(String messageId, Uint8List? rawPacket) { - try { - final now = DateTime.now(); - final tracker = SentMessageTracker( - messageId: messageId, - packetHashHex: 'pending', // Will be filled when we capture ANY packet - rawPacket: null, - sentTime: now, - expiryTime: now.add(_trackerTTL), - ); - - // Store by message ID temporarily - _sentMessageTrackers[messageId] = tracker; - debugPrint( - ' πŸ“€ [Echo] Tracking message $messageId (will match any GRP_TXT within 10000ms)', - ); - debugPrint(' πŸ“Š [Echo] Total trackers: ${_sentMessageTrackers.length}'); - - // Cleanup if too many trackers - if (_sentMessageTrackers.length > _maxTrackers) { - _cleanupOldestTrackers(); - } - } catch (e) { - debugPrint(' ⚠️ [Echo] Error tracking sent message: $e'); - } - } - - // Store our node hash (first byte of our public key) for sender identification - int? _ourNodeHash; - - /// Set our node hash for packet identification - void setOurNodeHash(int nodeHash) { - _ourNodeHash = nodeHash; - debugPrint( - ' πŸ”‘ [Echo] Our node hash set to: 0x${nodeHash.toRadixString(16).padLeft(2, '0')}', - ); - debugPrint( - ' ℹ️ [Echo] Will track packets containing our hash in the path', - ); - } - - /// Associate a captured packet with a sent message - /// - /// NEW STRATEGY: Firmware doesn't log our own transmissions, only echoes! - /// So we capture the FIRST GRP_TXT packet after sending (likely an echo), - /// then count additional instances of the same packet payload. - /// - /// Packet structure for GRP_TXT: - /// [0] = header (route type + payload type + version) - /// [1] = path_len - /// [2] = path[0] = sender's node hash - /// [3+] = rest of path + encrypted payload - void _associatePacketWithSentMessage(Uint8List rawPacket) { - try { - debugPrint( - ' πŸ” [Echo] _associatePacketWithSentMessage called, packet size: ${rawPacket.length}', - ); - - // Need at least 3 bytes: header + path_len + first path byte - if (rawPacket.length < 3) { - debugPrint(' ⚠️ [Echo] Packet too short for association'); - return; - } - - // Check if this is a GRP_TXT packet (payload type = 0x05) - final header = rawPacket[0]; - final payloadType = (header >> 2) & 0x0F; - debugPrint( - ' πŸ” [Echo] Association check - Payload type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}', - ); - if (payloadType != 0x05) { - // Not a group message - debugPrint(' ⚠️ [Echo] Not GRP_TXT, skipping association'); - return; - } - - final pathLen = rawPacket[1]; - debugPrint(' πŸ” [Echo] Path length for association: $pathLen'); - if (pathLen == 0) { - debugPrint(' ⚠️ [Echo] Path length is 0, skipping'); - return; - } - - final now = DateTime.now(); - - // Extract the path from the packet for unique echo tracking - final path = rawPacket.sublist(2, 2 + pathLen); - final pathSignature = path - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(':'); - - // Check if our node hash is in the path (meaning this is our message being rebroadcast) - final containsOurHash = - _ourNodeHash != null && path.contains(_ourNodeHash!); - if (!containsOurHash) { - // This packet doesn't have our hash in the path, so it's not our message - return; - } - - debugPrint( - ' βœ… [Echo] Packet contains our hash (0x${_ourNodeHash!.toRadixString(16).padLeft(2, '0')}) in path: $pathSignature', - ); - - // Extract encrypted payload (everything after path) - final payloadStart = 2 + pathLen; - final encryptedPayload = rawPacket.sublist(payloadStart); - // Hash only the encrypted payload to identify the same message - final payloadHash = _simplePacketHash(encryptedPayload); - - // Find pending trackers (within 10000ms window) - for (final entry in _sentMessageTrackers.entries.toList()) { - final tracker = entry.value; - if (tracker.packetHashHex != 'pending') continue; - - final timeSinceSent = now.difference(tracker.sentTime); - if (timeSinceSent.inMilliseconds > 10000) continue; // Outside window - - // This is the FIRST packet we see after sending - associate it! - // Remove old entry by message ID - _sentMessageTrackers.remove(entry.key); - - // Create updated tracker stored by payload hash - final updatedTracker = SentMessageTracker( - messageId: tracker.messageId, - packetHashHex: payloadHash, // Use payload hash to identify message - rawPacket: rawPacket, - sentTime: tracker.sentTime, - expiryTime: tracker.expiryTime, - echoCount: 1, // This first packet counts as an echo - uniqueEchoPaths: {pathSignature}, // Track unique paths - echoTimestamps: [now], - ); - - _sentMessageTrackers[payloadHash] = updatedTracker; - debugPrint(' πŸ“¦ [Echo] Captured packet for tracking!'); - debugPrint(' Message ID: ${tracker.messageId}'); - debugPrint(' Path: $pathSignature'); - debugPrint(' Time delta: ${timeSinceSent.inMilliseconds}ms'); - debugPrint(' Payload hash: $payloadHash'); - debugPrint(' Echo count: 1 (first detection)'); - - // Notify immediately that we have 1 echo - onMessageEchoDetected?.call(tracker.messageId, 1, 0, 0); - break; // Only associate with first pending tracker - } - } catch (e) { - debugPrint(' ⚠️ [Echo] Error associating packet: $e'); - } - } - - /// Remove expired trackers - void _cleanupExpiredTrackers() { - final expiredCount = _sentMessageTrackers.values - .where((t) => t.isExpired) - .length; - if (expiredCount > 0) { - debugPrint(' 🧹 [Echo] Cleaning up $expiredCount expired tracker(s)'); - } - _sentMessageTrackers.removeWhere((key, tracker) { - if (tracker.isExpired && tracker.packetHashHex == 'pending') { - debugPrint( - ' ⏱️ [Echo] Tracker expired without capturing: ${tracker.messageId}', - ); - } - return tracker.isExpired; - }); - } - - /// Remove oldest trackers when limit exceeded - void _cleanupOldestTrackers() { - if (_sentMessageTrackers.length <= _maxTrackers) return; - - // Sort by sent time and remove oldest - final sortedEntries = _sentMessageTrackers.entries.toList() - ..sort((a, b) => a.value.sentTime.compareTo(b.value.sentTime)); - - final toRemove = sortedEntries.take( - _sentMessageTrackers.length - _maxTrackers, - ); - for (final entry in toRemove) { - _sentMessageTrackers.remove(entry.key); - } - - debugPrint(' 🧹 [Echo] Cleaned up ${toRemove.length} old trackers'); - } - - /// Handle NewAdvert push (0x8A) - full contact info with location - void _handleNewAdvert(BufferReader reader) { - try { - final contact = FrameParser.parseContact(reader); - debugPrint( - ' βœ… [NewAdvert 0x8A] Parsed successfully: ${contact.advName}', - ); - debugPrint( - ' outPathLen: ${contact.outPathLen} (${contact.pathDescription})', - ); - if (contact.advLat != 0 || contact.advLon != 0) { - final location = contact.advertLocation; - if (location != null) { - debugPrint( - ' πŸ“ Location: ${location.latitude}, ${location.longitude}', - ); - } - } - onContactReceived?.call(contact); - } catch (e) { - debugPrint(' ❌ [NewAdvert] Parsing error: $e'); - onError?.call('NewAdvert parsing error: $e'); - } - } - - /// Handle SendConfirmed push - void _handleSendConfirmed(BufferReader reader) { - try { - final result = FrameParser.parseSendConfirmed(reader); - if (result.isNotEmpty) { - debugPrint(' βœ… [SendConfirmed] Message delivery confirmed'); - onMessageDelivered?.call( - result['ackCode'] as int, - result['roundTripTime'] as int, - ); - } - } catch (e) { - debugPrint(' ❌ [SendConfirmed] Parsing error: $e'); - } - } - - /// Handle MsgWaiting push - void _handleMsgWaiting(BufferReader reader) { - try { - debugPrint(' [MsgWaiting] New message(s) waiting in queue'); - onMessageWaiting?.call(); - } catch (e) { - debugPrint(' ❌ [MsgWaiting] Parsing error: $e'); - } - } - - /// Handle LoginSuccess push - void _handleLoginSuccess(BufferReader reader) { - try { - final result = FrameParser.parseLoginSuccess(reader); - if (result.isNotEmpty) { - debugPrint(' βœ… [LoginSuccess] Successfully logged into room'); - onLoginSuccess?.call( - result['publicKeyPrefix'] as Uint8List, - result['permissions'] as int, - result['isAdmin'] as bool, - result['tag'] as int, - ); - } - } catch (e) { - debugPrint(' ❌ [LoginSuccess] Parsing error: $e'); - onError?.call('Login success parsing error: $e'); - } - } - - /// Handle LoginFail push - void _handleLoginFail(BufferReader reader) { - try { - final publicKeyPrefix = FrameParser.parseLoginFail(reader); - if (publicKeyPrefix != null) { - debugPrint(' ❌ [LoginFail] Failed to login to room'); - onLoginFail?.call(publicKeyPrefix); - } - } catch (e) { - debugPrint(' ❌ [LoginFail] Parsing error: $e'); - onError?.call('Login fail parsing error: $e'); - } - } - - /// Handle StatusResponse push - void _handleStatusResponse(BufferReader reader) { - try { - final result = FrameParser.parseStatusResponse(reader); - if (result.isNotEmpty) { - // Try to decode as ASCII text if printable - try { - final statusData = result['statusData'] as Uint8List; - final statusText = utf8.decode(statusData, allowMalformed: true); - if (statusText.isNotEmpty && _isPrintableAscii(statusText)) { - debugPrint(' Status data (text): $statusText'); - } - } catch (e) { - // Not text data - } - - debugPrint(' βœ… [StatusResponse] Received status response'); - onStatusResponse?.call( - result['publicKeyPrefix'] as Uint8List, - result['statusData'] as Uint8List, - ); - } - } catch (e) { - debugPrint(' ❌ [StatusResponse] Parsing error: $e'); - onError?.call('Status response parsing error: $e'); - } - } - - /// Check if a string contains only printable ASCII characters - bool _isPrintableAscii(String text) { - for (int i = 0; i < text.length; i++) { - final code = text.codeUnitAt(i); - if (code < 32 || code > 126) { - if (code != 10 && code != 13 && code != 9) { - return false; - } - } - } - return true; - } - - /// Handle CurrentTime response - void _handleCurrentTime(BufferReader reader) { - try { - final deviceTime = FrameParser.parseCurrentTime(reader); - if (deviceTime != null) { - final appTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final drift = appTime - deviceTime; - debugPrint(' Clock drift: $drift seconds'); - } - debugPrint(' βœ… [CurrentTime] Parsed successfully'); - } catch (e) { - debugPrint(' ❌ [CurrentTime] Parsing error: $e'); - onError?.call('CurrentTime parsing error: $e'); - } - } - - /// Handle BatteryAndStorage response - void _handleBatteryAndStorage(BufferReader reader) { - try { - final result = FrameParser.parseBatteryAndStorage(reader); - if (result.isNotEmpty) { - onBatteryAndStorage?.call( - result['millivolts'] as int, - result['usedKb'] as int?, - result['totalKb'] as int?, - ); - } - debugPrint(' βœ… [BatteryAndStorage] Parsed successfully'); - } catch (e) { - debugPrint(' ❌ [BatteryAndStorage] Parsing error: $e'); - onError?.call('BatteryAndStorage parsing error: $e'); - } - } - - /// Handle ChannelInfo response - void _handleChannelInfo(BufferReader reader) { - try { - final info = FrameParser.parseChannelInfo(reader); - if (info.isNotEmpty) { - final channelIdx = info['channelIdx'] as int; - final channelName = info['channelName'] as String; - final secret = info['secret'] as Uint8List; - final flags = info['flags'] as int?; - - debugPrint(' βœ… [ChannelInfo] Channel $channelIdx: "$channelName"'); - debugPrint(' Name length: ${channelName.length}'); - debugPrint(' Name bytes: ${channelName.codeUnits.map((c) => c.toRadixString(16).padLeft(2, '0')).join(' ')}'); - debugPrint(' Secret: ${secret.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); - debugPrint(' isEmpty: ${channelName.isEmpty}'); - debugPrint(' Callback exists: ${onChannelInfoReceived != null}'); - - if (onChannelInfoReceived != null) { - debugPrint(' πŸ”” Calling onChannelInfoReceived callback...'); - onChannelInfoReceived!(channelIdx, channelName, secret, flags); - debugPrint(' βœ… Callback completed'); - } else { - debugPrint(' ⚠️ No callback registered!'); - } - } - } catch (e) { - debugPrint(' ❌ [ChannelInfo] Parsing error: $e'); - onError?.call('ChannelInfo parsing error: $e'); - } - } - - /// Handle Error response - void _handleError(BufferReader reader) { - try { - final errorCode = FrameParser.parseError(reader); - if (errorCode != null) { - final errorMsg = FrameParser.getErrorMessage(errorCode); - debugPrint(' ❌ [Error] $errorMsg'); - - // Complete whatever command is currently pending with an error. - // ACK commands are stored by their command code (not respOk=0), so - // completeCommandWithError(respOk, ...) would miss them. - _commandQueue?.completeCurrentCommandWithError( - errorMsg, - errorCode: errorCode, - ); - - // Special handling for ERR_CODE_NOT_FOUND (2) - contact not in radio - if (errorCode == 2) { - // ERR_CODE_NOT_FOUND - debugPrint( - ' ⚠️ [Error] Contact not found in radio - attempting auto-recovery', - ); - onContactNotFound?.call(_lastContactPublicKey); - } - - onError?.call(errorMsg, errorCode: errorCode); - } - } catch (e) { - debugPrint(' ❌ [Error] Parsing error: $e'); - } - } - - /// Track the last contact public key for retry logic - void setLastContactPublicKey(Uint8List? publicKey) { - _lastContactPublicKey = publicKey; - } - - /// Log a packet - void _logPacket( - Uint8List data, - PacketDirection direction, { - int? responseCode, - }) { - _packetLogs.add( - BlePacketLog( - timestamp: DateTime.now(), - rawData: data, - direction: direction, - responseCode: responseCode, - description: _getPacketDescription(responseCode), - ), - ); - - if (_packetLogs.length > _maxLogSize) { - _packetLogs.removeAt(0); - } - } - - /// Get human-readable description of packet - String? _getPacketDescription(int? code) { - // RX packets - response codes - switch (code) { - case 2: // respContactsStart - return 'Contacts Start'; - case 3: // respContact - return 'Contact Info'; - case 4: // respEndOfContacts - return 'End of Contacts'; - case 6: // respSent - return 'Message Sent'; - case 7: // respContactMsgRecv - return 'Contact Message'; - case 8: // respChannelMsgRecv - return 'Channel Message'; - case 0x8B: // pushTelemetryResponse - return 'Telemetry Data'; - case 13: // respDeviceInfo - return 'Device Info'; - case 5: // respSelfInfo - return 'Self Info'; - case 0x80: // pushAdvert - return 'Advertisement'; - case 0x81: // pushPathUpdated - return 'Path Updated'; - case 0x88: // pushLogRxData - return 'Log RX Data'; - case 0x8A: // pushNewAdvert - return 'New Advertisement'; - case 0x87: // pushStatusResponse - return 'Status Response'; - case 10: // respNoMoreMessages - return 'No More Messages'; - case 0: // respOk - return 'OK'; - case 1: // respErr - return 'ERROR'; - default: - return null; - } - } - - /// Reset packet counter - void resetCounter() { - _rxPacketCount = 0; - } - - /// Clear packet logs - void clearPacketLogs() { - _packetLogs.clear(); - } - - /// Dispose resources - Future dispose() async { - await _txSubscription?.cancel(); - _pendingContacts.clear(); - _sentMessageTrackers.clear(); - _packetLogs.clear(); - } -} diff --git a/lib/services/buffer_reader.dart b/lib/services/buffer_reader.dart deleted file mode 100644 index 1e24f59..0000000 --- a/lib/services/buffer_reader.dart +++ /dev/null @@ -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)'; - } -} diff --git a/lib/services/buffer_writer.dart b/lib/services/buffer_writer.dart deleted file mode 100644 index 30be898..0000000 --- a/lib/services/buffer_writer.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'dart:typed_data'; -import 'dart:convert'; - -/// Buffer writer for creating MeshCore protocol binary data -class BufferWriter { - final List _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` - void writeBytesFromList(List 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()})'; - } -} diff --git a/lib/services/cayenne_lpp_parser.dart b/lib/services/cayenne_lpp_parser.dart index f3cbecb..60379d2 100644 --- a/lib/services/cayenne_lpp_parser.dart +++ b/lib/services/cayenne_lpp_parser.dart @@ -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 diff --git a/lib/services/contact_storage_service.dart b/lib/services/contact_storage_service.dart index 36c9179..5934598 100644 --- a/lib/services/contact_storage_service.dart +++ b/lib/services/contact_storage_service.dart @@ -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'; diff --git a/lib/services/location_tracking_service.dart b/lib/services/location_tracking_service.dart index f9080f5..a836afa 100644 --- a/lib/services/location_tracking_service.dart +++ b/lib/services/location_tracking_service.dart @@ -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 /// diff --git a/lib/services/meshcore_ble_service.dart b/lib/services/meshcore_ble_service.dart deleted file mode 100644 index 817b601..0000000 --- a/lib/services/meshcore_ble_service.dart +++ /dev/null @@ -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 contacts); -typedef OnMessageCallback = void Function(Message message); -typedef OnTelemetryCallback = - void Function(Uint8List publicKey, Uint8List lppData); -typedef OnSelfInfoCallback = void Function(Map selfInfo); -typedef OnDeviceInfoCallback = void Function(Map 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 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 scanForDevices({ - Duration timeout = const Duration(seconds: 10), - }) { - return _connectionManager.scanForDevices(timeout: timeout); - } - - /// Connect to a MeshCore device - Future 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 disconnect() async { - await _connectionManager.disconnect(); - } - - /// Send initial device query and sync clock - Future _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>( - 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>( - 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 refreshDeviceInfo() async { - await _sendDeviceQuery(); - } - - /// Get contacts from device - Future 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 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 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 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 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 requestTelemetry( - Uint8List contactPublicKey, { - bool zeroHop = false, - }) async { - await _commandSender.writeData( - FrameBuilder.buildSendTelemetryReq(contactPublicKey, zeroHop: zeroHop), - ); - } - - /// Send binary request to contact - Future sendBinaryRequest({ - required Uint8List contactPublicKey, - required Uint8List requestData, - }) async { - await _commandSender.writeData( - FrameBuilder.buildSendBinaryReq( - contactPublicKey: contactPublicKey, - requestData: requestData, - ), - ); - } - - /// Get battery voltage and storage information - Future getBatteryAndStorage() async { - await _commandSender.writeData(FrameBuilder.buildGetBatteryAndStorage()); - } - - /// Legacy method name for backward compatibility - @Deprecated('Use getBatteryAndStorage() instead') - Future getBatteryVoltage() async { - await getBatteryAndStorage(); - } - - /// Sync next message from device queue - Future syncNextMessage() async { - await _commandSender.writeData(FrameBuilder.buildSyncNextMessage()); - } - - /// Get device time from companion radio - Future getDeviceTime() async { - await _commandSender.writeData(FrameBuilder.buildGetDeviceTime()); - } - - /// Set device time - Future setDeviceTime() async { - await _commandSender.writeData(FrameBuilder.buildSetDeviceTime()); - } - - /// Send self advertisement packet to mesh network - Future sendSelfAdvert({bool floodMode = true}) async { - await _commandSender.writeData( - FrameBuilder.buildSendSelfAdvert(floodMode: floodMode), - ); - } - - /// Set advertised name - Future setAdvertName(String name) async { - await _commandSender.writeDataAndWaitForAck( - FrameBuilder.buildSetAdvertName(name), - ); - } - - /// Set advertised latitude and longitude - Future 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 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 setTxPower(int powerDbm) async { - await _commandSender.writeDataAndWaitForAck( - FrameBuilder.buildSetTxPower(powerDbm), - ); - } - - /// Set other parameters - Future 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 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 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 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 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 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 setChannel({ - required int channelIdx, - required String channelName, - required List 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 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 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(); - } -} diff --git a/lib/services/meshcore_constants.dart b/lib/services/meshcore_constants.dart deleted file mode 100644 index 8e7bbfc..0000000 --- a/lib/services/meshcore_constants.dart +++ /dev/null @@ -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 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 -} diff --git a/lib/services/meshcore_opcode_names.dart b/lib/services/meshcore_opcode_names.dart deleted file mode 100644 index ea7dc1c..0000000 --- a/lib/services/meshcore_opcode_names.dart +++ /dev/null @@ -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 -} diff --git a/lib/services/protocol/frame_builder.dart b/lib/services/protocol/frame_builder.dart deleted file mode 100644 index 31727b9..0000000 --- a/lib/services/protocol/frame_builder.dart +++ /dev/null @@ -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 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(); - } -} diff --git a/lib/services/protocol/frame_parser.dart b/lib/services/protocol/frame_parser.dart deleted file mode 100644 index c56c6a2..0000000 --- a/lib/services/protocol/frame_parser.dart +++ /dev/null @@ -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 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: ": " - 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 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 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 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 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 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 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 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 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 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'; - } - } -} diff --git a/lib/utils/sample_data_generator.dart b/lib/utils/sample_data_generator.dart index da40794..52eb12d 100644 --- a/lib/utils/sample_data_generator.dart +++ b/lib/utils/sample_data_generator.dart @@ -3,7 +3,6 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; import '../models/contact.dart'; -import '../models/contact_telemetry.dart'; import '../models/message.dart'; import '../l10n/app_localizations.dart'; diff --git a/pubspec.lock b/pubspec.lock index cf0312f..1e95ffa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -589,6 +589,13 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.2" + meshcore_client: + dependency: "direct main" + description: + path: "../meshcore_client" + relative: true + source: path + version: "0.1.0" meta: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 038f764..47b85d8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,10 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + # MeshCore BLE protocol client + meshcore_client: + path: ../meshcore_client + # BLE connectivity flutter_blue_plus: ^2.0.0 diff --git a/test/services/cayenne_lpp_parser_test.dart b/test/services/cayenne_lpp_parser_test.dart index 907944f..2ee41bc 100644 --- a/test/services/cayenne_lpp_parser_test.dart +++ b/test/services/cayenne_lpp_parser_test.dart @@ -1,9 +1,8 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart'; -import 'package:meshcore_sar_app/models/contact_telemetry.dart'; import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart'; -import 'package:meshcore_sar_app/services/meshcore_constants.dart'; +import 'package:meshcore_client/meshcore_client.dart'; void main() { group('CayenneLppParser - GPS Codec Tests', () {