mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
refactor: extract BLE protocol stack into meshcore_client package
Move BLE communication layer (command queue, frame parser/builder, protocol constants, data models) into a standalone reusable Dart package at ../meshcore_client. App model files become thin re-export wrappers, keeping all existing consumers working without import changes.
This commit is contained in:
@@ -1,40 +1 @@
|
|||||||
import 'package:latlong2/latlong.dart';
|
export 'package:meshcore_client/meshcore_client.dart' show AdvertLocation;
|
||||||
|
|
||||||
/// 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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,121 +1,2 @@
|
|||||||
import 'dart:typed_data';
|
export 'package:meshcore_client/meshcore_client.dart'
|
||||||
import '../services/meshcore_opcode_names.dart';
|
show BlePacketLog, PacketDirection, LogRxDataInfo;
|
||||||
|
|
||||||
/// Decoded LOG_RX_DATA packet structure
|
|
||||||
class LogRxDataInfo {
|
|
||||||
final int? airtimeMs;
|
|
||||||
final Uint8List? senderPublicKey;
|
|
||||||
final int? ackCode;
|
|
||||||
final List<String> 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 = <String>[];
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,364 +1,17 @@
|
|||||||
import 'dart:math';
|
export 'package:meshcore_client/meshcore_client.dart'
|
||||||
import 'dart:typed_data';
|
show Contact, ContactType, ContactTelemetry, AdvertLocation;
|
||||||
import 'package:latlong2/latlong.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'contact_telemetry.dart';
|
|
||||||
import 'advert_location.dart';
|
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
|
|
||||||
/// MeshCore contact types
|
extension ContactLocalization on Contact {
|
||||||
enum ContactType {
|
/// Returns the localized display name for special contacts (e.g. Public Channel).
|
||||||
none(0),
|
/// For all other contacts, returns [displayName].
|
||||||
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<AdvertLocation> 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<AdvertLocation>? 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)
|
|
||||||
String getLocalizedDisplayName(BuildContext context) {
|
String getLocalizedDisplayName(BuildContext context) {
|
||||||
// Check if this is the Public Channel (all-zeros public key)
|
|
||||||
if (isPublicChannel) {
|
if (isPublicChannel) {
|
||||||
return AppLocalizations.of(context)!.publicChannel;
|
return AppLocalizations.of(context)!.publicChannel;
|
||||||
}
|
}
|
||||||
// For all other contacts, use the regular display name
|
|
||||||
return displayName;
|
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<AdvertLocation>? 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1 @@
|
|||||||
import 'package:latlong2/latlong.dart';
|
export 'package:meshcore_client/meshcore_client.dart' show ContactTelemetry;
|
||||||
|
|
||||||
/// 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<String, dynamic>? 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<String, dynamic>? 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)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,264 +1,40 @@
|
|||||||
|
export 'package:meshcore_client/meshcore_client.dart'
|
||||||
|
show
|
||||||
|
Message,
|
||||||
|
MessageType,
|
||||||
|
MessageTextType,
|
||||||
|
MessageDeliveryStatus,
|
||||||
|
MessageRecipient;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
import 'sar_marker.dart';
|
import 'sar_marker.dart';
|
||||||
|
|
||||||
/// Message recipient tracking for grouped messages
|
extension MessageSarExtension on Message {
|
||||||
class MessageRecipient {
|
/// Infer the [SarMarkerType] from stored SAR fields.
|
||||||
final Uint8List publicKey; // Full public key
|
/// Returns null if this is not a SAR marker message.
|
||||||
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<MessageRecipient>?
|
|
||||||
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
|
|
||||||
SarMarkerType? get sarMarkerType {
|
SarMarkerType? get sarMarkerType {
|
||||||
if (!isSarMarker) return null;
|
if (!isSarMarker) return null;
|
||||||
|
|
||||||
// If we have a custom emoji stored, infer type from it
|
|
||||||
if (sarCustomEmoji != null && sarCustomEmoji!.isNotEmpty) {
|
if (sarCustomEmoji != null && sarCustomEmoji!.isNotEmpty) {
|
||||||
return SarMarkerType.fromEmoji(sarCustomEmoji!);
|
return SarMarkerType.fromEmoji(sarCustomEmoji!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, parse the message text to extract the emoji
|
|
||||||
final trimmed = text.trim();
|
final trimmed = text.trim();
|
||||||
if (!trimmed.startsWith('S:')) return null;
|
if (!trimmed.startsWith('S:')) return null;
|
||||||
|
|
||||||
// Extract emoji from format: S:<emoji>:... or S:<emoji>:<colorIndex>:...
|
|
||||||
final parts = trimmed.split(':');
|
final parts = trimmed.split(':');
|
||||||
if (parts.length < 3) return null;
|
if (parts.length < 3) return null;
|
||||||
|
|
||||||
final emoji = parts[1];
|
return SarMarkerType.fromEmoji(parts[1]);
|
||||||
return SarMarkerType.fromEmoji(emoji);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get sender public key as hex string
|
/// Convert to a [SarMarker] if this message contains SAR data.
|
||||||
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
|
|
||||||
SarMarker? toSarMarker() {
|
SarMarker? toSarMarker() {
|
||||||
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
|
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug: Check what's in sarNotes
|
|
||||||
debugPrint('📍 [Message.toSarMarker] Converting to marker:');
|
debugPrint('📍 [Message.toSarMarker] Converting to marker:');
|
||||||
debugPrint(' message.text: "$text"');
|
debugPrint(' message.text: "$text"');
|
||||||
debugPrint(' message.sarNotes: "$sarNotes"');
|
debugPrint(' message.sarNotes: "$sarNotes"');
|
||||||
@@ -272,220 +48,9 @@ class Message {
|
|||||||
timestamp: sentAt,
|
timestamp: sentAt,
|
||||||
senderPublicKey: senderPublicKeyPrefix,
|
senderPublicKey: senderPublicKeyPrefix,
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
notes: sarNotes, // Use dedicated notes field instead of full text
|
notes: sarNotes,
|
||||||
customEmoji: sarCustomEmoji, // Preserve custom emoji for unknown types
|
customEmoji: sarCustomEmoji,
|
||||||
colorIndex: sarColorIndex, // Pass through color index
|
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<String, dynamic>? 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<MessageRecipient>? 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +1 @@
|
|||||||
import 'dart:typed_data';
|
export 'package:meshcore_client/meshcore_client.dart' show SentMessageTracker;
|
||||||
|
|
||||||
/// 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<String> uniqueEchoPaths;
|
|
||||||
|
|
||||||
/// Timestamps when echoes were detected
|
|
||||||
final List<DateTime> echoTimestamps;
|
|
||||||
|
|
||||||
SentMessageTracker({
|
|
||||||
required this.messageId,
|
|
||||||
required this.packetHashHex,
|
|
||||||
this.rawPacket,
|
|
||||||
required this.sentTime,
|
|
||||||
required this.expiryTime,
|
|
||||||
this.echoCount = 0,
|
|
||||||
Set<String>? uniqueEchoPaths,
|
|
||||||
List<DateTime>? 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)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,12 +4,9 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import '../models/device_info.dart';
|
import '../models/device_info.dart';
|
||||||
import '../models/contact.dart';
|
|
||||||
import '../models/message.dart';
|
|
||||||
import '../models/room_login_state.dart';
|
import '../models/room_login_state.dart';
|
||||||
import '../models/sse_server_config.dart';
|
import '../models/sse_server_config.dart';
|
||||||
import '../services/meshcore_ble_service.dart';
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
import '../services/meshcore_constants.dart';
|
|
||||||
import '../services/sse_server_service.dart';
|
import '../services/sse_server_service.dart';
|
||||||
import '../services/sse_client_service.dart';
|
import '../services/sse_client_service.dart';
|
||||||
import '../utils/sar_message_parser.dart';
|
import '../utils/sar_message_parser.dart';
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import '../models/ble_packet_log.dart';
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
import '../services/meshcore_ble_service.dart';
|
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class PacketLogScreen extends StatefulWidget {
|
class PacketLogScreen extends StatefulWidget {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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
|
/// Background location tracking service for SAR operations
|
||||||
/// Tracks user location and sends periodic updates via MeshCore BLE
|
/// Tracks user location and sends periodic updates via MeshCore BLE
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
/// Type of response expected from a command
|
|
||||||
enum CommandResponseType {
|
|
||||||
/// No response expected (fire-and-forget)
|
|
||||||
none,
|
|
||||||
|
|
||||||
/// Wait for RESP_CODE_OK (0) or RESP_CODE_ERR (1)
|
|
||||||
ack,
|
|
||||||
|
|
||||||
/// Wait for specific response code with data
|
|
||||||
data,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents a queued BLE command
|
|
||||||
class QueuedCommand<T> {
|
|
||||||
/// The command data to send
|
|
||||||
final Uint8List data;
|
|
||||||
|
|
||||||
/// Command code (first byte of data)
|
|
||||||
final int commandCode;
|
|
||||||
|
|
||||||
/// Type of response expected
|
|
||||||
final CommandResponseType responseType;
|
|
||||||
|
|
||||||
/// Expected response code (for data type commands)
|
|
||||||
final int? expectedResponseCode;
|
|
||||||
|
|
||||||
/// Completer to signal command completion
|
|
||||||
final Completer<T> completer;
|
|
||||||
|
|
||||||
/// Timeout duration for this command
|
|
||||||
final Duration timeout;
|
|
||||||
|
|
||||||
/// Timestamp when command was enqueued
|
|
||||||
final DateTime enqueuedAt;
|
|
||||||
|
|
||||||
QueuedCommand({
|
|
||||||
required this.data,
|
|
||||||
required this.commandCode,
|
|
||||||
required this.responseType,
|
|
||||||
this.expectedResponseCode,
|
|
||||||
required this.completer,
|
|
||||||
required this.timeout,
|
|
||||||
}) : enqueuedAt = DateTime.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// BLE command queue with mutex lock and inter-command delays
|
|
||||||
///
|
|
||||||
/// Ensures that:
|
|
||||||
/// - Only one command executes at a time
|
|
||||||
/// - 100ms delay between all commands
|
|
||||||
/// - Commands can wait for ACK or specific responses
|
|
||||||
/// - Timeouts are enforced
|
|
||||||
class BleCommandQueue {
|
|
||||||
// Queue of pending commands
|
|
||||||
final List<QueuedCommand> _queue = [];
|
|
||||||
|
|
||||||
// Mutex lock using Completer
|
|
||||||
Completer<void> _lock = Completer<void>()..complete();
|
|
||||||
|
|
||||||
// Whether queue is currently processing
|
|
||||||
bool _isProcessing = false;
|
|
||||||
|
|
||||||
// Pending responses mapped by command code
|
|
||||||
final Map<int, QueuedCommand> _pendingResponses = {};
|
|
||||||
|
|
||||||
// Last command execution timestamp
|
|
||||||
DateTime? _lastCommandTime;
|
|
||||||
|
|
||||||
// Minimum delay between commands (milliseconds)
|
|
||||||
static const int _minDelayMs = 100;
|
|
||||||
|
|
||||||
// Callbacks
|
|
||||||
VoidCallback? onQueueEmpty;
|
|
||||||
void Function(int queueSize)? onQueueSizeChanged;
|
|
||||||
|
|
||||||
/// Enqueue a command and wait for it to complete
|
|
||||||
///
|
|
||||||
/// [data] - The command data to send
|
|
||||||
/// [commandCode] - Command code (first byte)
|
|
||||||
/// [responseType] - Type of response expected
|
|
||||||
/// [expectedResponseCode] - For data responses, the expected response code
|
|
||||||
/// [timeout] - Maximum time to wait for response
|
|
||||||
///
|
|
||||||
/// Returns a Future that completes when the command receives its response
|
|
||||||
/// or throws TimeoutException if timeout expires.
|
|
||||||
Future<T> enqueue<T>({
|
|
||||||
required Uint8List data,
|
|
||||||
required int commandCode,
|
|
||||||
required CommandResponseType responseType,
|
|
||||||
int? expectedResponseCode,
|
|
||||||
Duration? timeout,
|
|
||||||
}) async {
|
|
||||||
// Determine timeout based on response type
|
|
||||||
final cmdTimeout =
|
|
||||||
timeout ??
|
|
||||||
(responseType == CommandResponseType.data
|
|
||||||
? const Duration(seconds: 10)
|
|
||||||
: const Duration(seconds: 5));
|
|
||||||
|
|
||||||
// Create queued command
|
|
||||||
final command = QueuedCommand<T>(
|
|
||||||
data: data,
|
|
||||||
commandCode: commandCode,
|
|
||||||
responseType: responseType,
|
|
||||||
expectedResponseCode: expectedResponseCode,
|
|
||||||
completer: Completer<T>(),
|
|
||||||
timeout: cmdTimeout,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add to queue
|
|
||||||
_queue.add(command);
|
|
||||||
onQueueSizeChanged?.call(_queue.length);
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'📋 [CommandQueue] Enqueued command 0x${commandCode.toRadixString(16).padLeft(2, '0')} (queue size: ${_queue.length})',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Start processing if not already running
|
|
||||||
if (!_isProcessing) {
|
|
||||||
_processQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for command to complete or timeout
|
|
||||||
return command.completer.future.timeout(
|
|
||||||
cmdTimeout,
|
|
||||||
onTimeout: () {
|
|
||||||
debugPrint(
|
|
||||||
'⏱️ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out after ${cmdTimeout.inSeconds}s',
|
|
||||||
);
|
|
||||||
_pendingResponses.remove(commandCode);
|
|
||||||
throw TimeoutException(
|
|
||||||
'Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Process the command queue
|
|
||||||
Future<void> _processQueue() async {
|
|
||||||
if (_isProcessing) return;
|
|
||||||
_isProcessing = true;
|
|
||||||
|
|
||||||
while (_queue.isNotEmpty) {
|
|
||||||
// Wait for lock
|
|
||||||
await _lock.future;
|
|
||||||
|
|
||||||
// Get next command
|
|
||||||
final command = _queue.removeAt(0);
|
|
||||||
onQueueSizeChanged?.call(_queue.length);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Enforce minimum delay between commands
|
|
||||||
if (_lastCommandTime != null) {
|
|
||||||
final elapsed = DateTime.now().difference(_lastCommandTime!);
|
|
||||||
final remainingDelay = _minDelayMs - elapsed.inMilliseconds;
|
|
||||||
|
|
||||||
if (remainingDelay > 0) {
|
|
||||||
debugPrint(
|
|
||||||
'⏸️ [CommandQueue] Waiting ${remainingDelay}ms before next command',
|
|
||||||
);
|
|
||||||
await Future.delayed(Duration(milliseconds: remainingDelay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new lock for next command
|
|
||||||
_lock = Completer<void>();
|
|
||||||
|
|
||||||
// Register for response if needed
|
|
||||||
if (command.responseType != CommandResponseType.none) {
|
|
||||||
final responseKey = command.responseType == CommandResponseType.ack
|
|
||||||
? command.commandCode
|
|
||||||
: (command.expectedResponseCode ?? command.commandCode);
|
|
||||||
_pendingResponses[responseKey] = command;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute command (handled by BleCommandSender)
|
|
||||||
// The completer will be completed by completeCommand() when response arrives
|
|
||||||
debugPrint(
|
|
||||||
'📤 [CommandQueue] Executing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')}',
|
|
||||||
);
|
|
||||||
|
|
||||||
// For fire-and-forget commands, complete immediately
|
|
||||||
if (command.responseType == CommandResponseType.none) {
|
|
||||||
command.completer.complete(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last command time
|
|
||||||
_lastCommandTime = DateTime.now();
|
|
||||||
|
|
||||||
// Release lock after minimum delay
|
|
||||||
Future.delayed(const Duration(milliseconds: _minDelayMs), () {
|
|
||||||
if (!_lock.isCompleted) {
|
|
||||||
_lock.complete();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [CommandQueue] Error processing command: $e');
|
|
||||||
if (!command.completer.isCompleted) {
|
|
||||||
command.completer.completeError(e);
|
|
||||||
}
|
|
||||||
// Release lock on error
|
|
||||||
if (!_lock.isCompleted) {
|
|
||||||
_lock.complete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_isProcessing = false;
|
|
||||||
onQueueEmpty?.call();
|
|
||||||
debugPrint('✅ [CommandQueue] Queue empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Complete a pending command with response data
|
|
||||||
///
|
|
||||||
/// Called by BleResponseHandler when a response is received
|
|
||||||
void completeCommand<T>(int responseCode, T data) {
|
|
||||||
final command = _pendingResponses.remove(responseCode);
|
|
||||||
if (command != null) {
|
|
||||||
debugPrint(
|
|
||||||
'✅ [CommandQueue] Completing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')} with response 0x${responseCode.toRadixString(16).padLeft(2, '0')}',
|
|
||||||
);
|
|
||||||
if (!command.completer.isCompleted) {
|
|
||||||
command.completer.complete(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Complete a pending command with error
|
|
||||||
///
|
|
||||||
/// Called by BleResponseHandler when RESP_CODE_ERR is received
|
|
||||||
void completeCommandWithError(
|
|
||||||
int commandCode,
|
|
||||||
String error, {
|
|
||||||
int? errorCode,
|
|
||||||
}) {
|
|
||||||
final command = _pendingResponses.remove(commandCode);
|
|
||||||
if (command != null) {
|
|
||||||
debugPrint(
|
|
||||||
'❌ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} failed: $error (code: $errorCode)',
|
|
||||||
);
|
|
||||||
if (!command.completer.isCompleted) {
|
|
||||||
command.completer.completeError(
|
|
||||||
Exception('Command failed: $error (error code: $errorCode)'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Complete all currently pending commands with an error
|
|
||||||
///
|
|
||||||
/// Used when RESP_CODE_ERR arrives without a way to identify which command
|
|
||||||
/// caused it. Since the queue processes one command at a time, at most one
|
|
||||||
/// command is pending at any given moment.
|
|
||||||
void completeCurrentCommandWithError(String error, {int? errorCode}) {
|
|
||||||
for (final entry in _pendingResponses.entries.toList()) {
|
|
||||||
final command = _pendingResponses.remove(entry.key);
|
|
||||||
if (command != null && !command.completer.isCompleted) {
|
|
||||||
debugPrint(
|
|
||||||
'❌ [CommandQueue] Command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')} failed: $error (code: $errorCode)',
|
|
||||||
);
|
|
||||||
command.completer.completeError(
|
|
||||||
Exception('Command failed: $error (error code: $errorCode)'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current queue size
|
|
||||||
int get queueSize => _queue.length;
|
|
||||||
|
|
||||||
/// Get number of pending responses
|
|
||||||
int get pendingResponseCount => _pendingResponses.length;
|
|
||||||
|
|
||||||
/// Check if queue is empty
|
|
||||||
bool get isEmpty => _queue.isEmpty;
|
|
||||||
|
|
||||||
/// Check if queue is processing
|
|
||||||
bool get isProcessing => _isProcessing;
|
|
||||||
|
|
||||||
/// Clear all pending commands (use with caution)
|
|
||||||
void clear() {
|
|
||||||
debugPrint(
|
|
||||||
'🗑️ [CommandQueue] Clearing queue (${_queue.length} commands, ${_pendingResponses.length} pending responses)',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Complete all pending commands with error
|
|
||||||
for (final command in _pendingResponses.values) {
|
|
||||||
if (!command.completer.isCompleted) {
|
|
||||||
command.completer.completeError(Exception('Queue cleared'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_queue.clear();
|
|
||||||
_pendingResponses.clear();
|
|
||||||
onQueueSizeChanged?.call(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispose resources
|
|
||||||
void dispose() {
|
|
||||||
clear();
|
|
||||||
if (!_lock.isCompleted) {
|
|
||||||
_lock.complete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
|
||||||
import '../meshcore_opcode_names.dart';
|
|
||||||
import '../../models/ble_packet_log.dart';
|
|
||||||
import 'ble_command_queue.dart';
|
|
||||||
|
|
||||||
/// Callback types for sender events
|
|
||||||
typedef OnErrorCallback = void Function(String error);
|
|
||||||
|
|
||||||
/// Sends commands to the BLE device
|
|
||||||
class BleCommandSender {
|
|
||||||
BluetoothCharacteristic? _rxCharacteristic;
|
|
||||||
int _txPacketCount = 0;
|
|
||||||
final List<BlePacketLog> _packetLogs = [];
|
|
||||||
static const int _maxLogSize = 1000;
|
|
||||||
|
|
||||||
// Command queue for serialization and response waiting
|
|
||||||
final BleCommandQueue _commandQueue = BleCommandQueue();
|
|
||||||
|
|
||||||
// Callbacks
|
|
||||||
OnErrorCallback? onError;
|
|
||||||
VoidCallback? onTxActivity;
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
int get txPacketCount => _txPacketCount;
|
|
||||||
List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs);
|
|
||||||
BleCommandQueue get commandQueue => _commandQueue;
|
|
||||||
|
|
||||||
/// Set the RX characteristic to write to
|
|
||||||
void setRxCharacteristic(BluetoothCharacteristic? characteristic) {
|
|
||||||
_rxCharacteristic = characteristic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write data to RX characteristic (fire-and-forget, no response expected)
|
|
||||||
///
|
|
||||||
/// This method is for commands that don't expect any response.
|
|
||||||
/// The command is queued and executed with proper spacing, but we don't wait
|
|
||||||
/// for any acknowledgment.
|
|
||||||
Future<void> writeData(Uint8List data) async {
|
|
||||||
if (_rxCharacteristic == null) {
|
|
||||||
throw Exception('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
final commandCode = data.isNotEmpty ? data[0] : 0;
|
|
||||||
|
|
||||||
// Enqueue the command (fire-and-forget)
|
|
||||||
await _commandQueue.enqueue<void>(
|
|
||||||
data: data,
|
|
||||||
commandCode: commandCode,
|
|
||||||
responseType: CommandResponseType.none,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Actually send the data
|
|
||||||
await _sendToDevice(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write data and wait for ACK (RESP_CODE_OK or RESP_CODE_ERR)
|
|
||||||
///
|
|
||||||
/// This method should be used for setup commands that return RESP_CODE_OK (0)
|
|
||||||
/// on success or RESP_CODE_ERR (1) on failure.
|
|
||||||
///
|
|
||||||
/// Examples: setAdvertLatLon, setAdvertName, setRadioParams, etc.
|
|
||||||
Future<void> writeDataAndWaitForAck(Uint8List data) async {
|
|
||||||
if (_rxCharacteristic == null) {
|
|
||||||
throw Exception('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
final commandCode = data.isNotEmpty ? data[0] : 0;
|
|
||||||
|
|
||||||
// Enqueue command but don't await yet — data must be sent to the device
|
|
||||||
// before it can respond with an ACK. Awaiting before send would deadlock.
|
|
||||||
final ackFuture = _commandQueue.enqueue<void>(
|
|
||||||
data: data,
|
|
||||||
commandCode: commandCode,
|
|
||||||
responseType: CommandResponseType.ack,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Actually send the data
|
|
||||||
await _sendToDevice(data);
|
|
||||||
|
|
||||||
// Now wait for the ACK response
|
|
||||||
return ackFuture;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write data and wait for specific response
|
|
||||||
///
|
|
||||||
/// This method should be used for query commands that return specific data.
|
|
||||||
///
|
|
||||||
/// Examples:
|
|
||||||
/// - CMD_DEVICE_QUERY → RESP_CODE_DEVICE_INFO
|
|
||||||
/// - CMD_APP_START → RESP_CODE_SELF_INFO
|
|
||||||
/// - CMD_GET_CONTACTS → RESP_CODE_CONTACTS_START
|
|
||||||
Future<T> writeDataAndWaitForResponse<T>(
|
|
||||||
Uint8List data,
|
|
||||||
int expectedResponseCode,
|
|
||||||
) async {
|
|
||||||
if (_rxCharacteristic == null) {
|
|
||||||
throw Exception('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
final commandCode = data.isNotEmpty ? data[0] : 0;
|
|
||||||
|
|
||||||
// Enqueue the command (wait for specific response)
|
|
||||||
final responseFuture = _commandQueue.enqueue<T>(
|
|
||||||
data: data,
|
|
||||||
commandCode: commandCode,
|
|
||||||
responseType: CommandResponseType.data,
|
|
||||||
expectedResponseCode: expectedResponseCode,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Actually send the data
|
|
||||||
await _sendToDevice(data);
|
|
||||||
|
|
||||||
// Wait for response
|
|
||||||
return responseFuture;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal method to actually send data to the BLE device
|
|
||||||
Future<void> _sendToDevice(Uint8List data) async {
|
|
||||||
if (_rxCharacteristic == null) {
|
|
||||||
throw Exception('Not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Extract command code from first byte
|
|
||||||
final commandCode = data.isNotEmpty ? data[0] : null;
|
|
||||||
final opcodeName = commandCode != null
|
|
||||||
? MeshCoreOpcodeNames.getCommandName(commandCode)
|
|
||||||
: 'UNKNOWN';
|
|
||||||
final opcodeHex = commandCode != null
|
|
||||||
? '0x${commandCode.toRadixString(16).padLeft(2, '0').toUpperCase()}'
|
|
||||||
: 'N/A';
|
|
||||||
|
|
||||||
debugPrint('📤 [TX] Sending command: $opcodeName ($opcodeHex)');
|
|
||||||
debugPrint(' Data size: ${data.length} bytes');
|
|
||||||
debugPrint(
|
|
||||||
' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if the characteristic supports write without response
|
|
||||||
final supportsWriteWithoutResponse =
|
|
||||||
_rxCharacteristic!.properties.writeWithoutResponse;
|
|
||||||
final supportsWrite = _rxCharacteristic!.properties.write;
|
|
||||||
|
|
||||||
if (supportsWriteWithoutResponse) {
|
|
||||||
await _rxCharacteristic!.write(data, withoutResponse: true);
|
|
||||||
} else if (supportsWrite) {
|
|
||||||
await _rxCharacteristic!.write(data, withoutResponse: false);
|
|
||||||
} else {
|
|
||||||
throw Exception('Characteristic does not support write operations');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log TX packet
|
|
||||||
_logPacket(data, PacketDirection.tx, responseCode: commandCode);
|
|
||||||
|
|
||||||
// Increment TX packet counter and trigger activity indicator
|
|
||||||
_txPacketCount++;
|
|
||||||
onTxActivity?.call();
|
|
||||||
|
|
||||||
debugPrint('✅ [TX] Command sent successfully');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [TX] Write error: $e');
|
|
||||||
onError?.call('Write error: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log a packet
|
|
||||||
void _logPacket(
|
|
||||||
Uint8List data,
|
|
||||||
PacketDirection direction, {
|
|
||||||
int? responseCode,
|
|
||||||
}) {
|
|
||||||
// Add new packet
|
|
||||||
_packetLogs.add(
|
|
||||||
BlePacketLog(
|
|
||||||
timestamp: DateTime.now(),
|
|
||||||
rawData: data,
|
|
||||||
direction: direction,
|
|
||||||
responseCode: responseCode,
|
|
||||||
description: _getPacketDescription(responseCode),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Limit log size to prevent memory issues
|
|
||||||
if (_packetLogs.length > _maxLogSize) {
|
|
||||||
_packetLogs.removeAt(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get human-readable description of packet
|
|
||||||
String? _getPacketDescription(int? code) {
|
|
||||||
// TX packets - command codes
|
|
||||||
switch (code) {
|
|
||||||
case 4: // cmdGetContacts
|
|
||||||
return 'Get Contacts';
|
|
||||||
case 2: // cmdSendTxtMsg
|
|
||||||
return 'Send Text Message';
|
|
||||||
case 3: // cmdSendChannelTxtMsg
|
|
||||||
return 'Send Channel Message';
|
|
||||||
case 39: // cmdSendTelemetryReq
|
|
||||||
return 'Request Telemetry';
|
|
||||||
case 22: // cmdDeviceQuery
|
|
||||||
return 'Device Query';
|
|
||||||
case 1: // cmdAppStart
|
|
||||||
return 'App Start';
|
|
||||||
case 27: // cmdSendStatusReq
|
|
||||||
return 'Status Request';
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset packet counter
|
|
||||||
void resetCounter() {
|
|
||||||
_txPacketCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear packet logs
|
|
||||||
void clearPacketLogs() {
|
|
||||||
_packetLogs.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispose resources
|
|
||||||
void dispose() {
|
|
||||||
_commandQueue.dispose();
|
|
||||||
_rxCharacteristic = null;
|
|
||||||
_packetLogs.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
|
||||||
import '../meshcore_constants.dart';
|
|
||||||
|
|
||||||
/// Callback types for connection events
|
|
||||||
typedef OnConnectionStateCallback = void Function(bool isConnected);
|
|
||||||
typedef OnErrorCallback = void Function(String error);
|
|
||||||
typedef OnReconnectionAttemptCallback =
|
|
||||||
void Function(int attemptNumber, int maxAttempts);
|
|
||||||
typedef OnRssiUpdateCallback = void Function(int rssi);
|
|
||||||
|
|
||||||
/// Manages BLE connection lifecycle with automatic reconnection
|
|
||||||
class BleConnectionManager {
|
|
||||||
BluetoothDevice? _device;
|
|
||||||
BluetoothCharacteristic? _rxCharacteristic;
|
|
||||||
BluetoothCharacteristic? _txCharacteristic;
|
|
||||||
bool _isConnected = false;
|
|
||||||
|
|
||||||
// Reconnection state
|
|
||||||
bool _reconnectionEnabled = true;
|
|
||||||
bool _isReconnecting = false;
|
|
||||||
int _reconnectionAttempt = 0;
|
|
||||||
Timer? _reconnectionTimer;
|
|
||||||
StreamSubscription<BluetoothConnectionState>? _connectionStateSubscription;
|
|
||||||
|
|
||||||
// RSSI monitoring
|
|
||||||
Timer? _rssiTimer;
|
|
||||||
int? _lastRssi;
|
|
||||||
|
|
||||||
// SAR-optimized reconnection: ~15 minutes total
|
|
||||||
// Pattern: Fast retries first (for temporary issues), then slower retries (for extended disconnections)
|
|
||||||
static const int _maxReconnectionAttempts = 30;
|
|
||||||
static const List<int> _reconnectionDelaysMs = [
|
|
||||||
2000, // 2s - immediate retry
|
|
||||||
3000, // 3s - quick retry
|
|
||||||
5000, // 5s - fast retry
|
|
||||||
10000, // 10s - moderate retry
|
|
||||||
15000, // 15s - longer retry
|
|
||||||
30000, // 30s - extended retry
|
|
||||||
30000, // 30s - keep trying every 30s after this
|
|
||||||
]; // Total: ~15 minutes of reconnection attempts
|
|
||||||
|
|
||||||
// Callbacks
|
|
||||||
OnConnectionStateCallback? onConnectionStateChanged;
|
|
||||||
OnErrorCallback? onError;
|
|
||||||
OnReconnectionAttemptCallback? onReconnectionAttempt;
|
|
||||||
OnRssiUpdateCallback? onRssiUpdate;
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
bool get isConnected => _isConnected;
|
|
||||||
bool get isReconnecting => _isReconnecting;
|
|
||||||
int get reconnectionAttempt => _reconnectionAttempt;
|
|
||||||
int get maxReconnectionAttempts => _maxReconnectionAttempts;
|
|
||||||
BluetoothDevice? get device => _device;
|
|
||||||
BluetoothCharacteristic? get rxCharacteristic => _rxCharacteristic;
|
|
||||||
BluetoothCharacteristic? get txCharacteristic => _txCharacteristic;
|
|
||||||
|
|
||||||
/// Scan for MeshCore devices
|
|
||||||
Stream<ScanResult> scanForDevices({
|
|
||||||
Duration timeout = const Duration(seconds: 10),
|
|
||||||
}) async* {
|
|
||||||
try {
|
|
||||||
debugPrint('🔍 [BLE] Starting scan for MeshCore devices...');
|
|
||||||
debugPrint(' Service UUID: ${MeshCoreConstants.bleServiceUuid}');
|
|
||||||
debugPrint(' Timeout: ${timeout.inSeconds}s');
|
|
||||||
|
|
||||||
await FlutterBluePlus.startScan(
|
|
||||||
timeout: timeout,
|
|
||||||
withServices: [Guid(MeshCoreConstants.bleServiceUuid)],
|
|
||||||
);
|
|
||||||
debugPrint('✅ [BLE] Scan started successfully');
|
|
||||||
|
|
||||||
int deviceCount = 0;
|
|
||||||
await for (final scanResult in FlutterBluePlus.scanResults) {
|
|
||||||
debugPrint(
|
|
||||||
'📡 [BLE] Scan results batch received: ${scanResult.length} results',
|
|
||||||
);
|
|
||||||
for (final result in scanResult) {
|
|
||||||
debugPrint(
|
|
||||||
' Device: ${result.device.platformName} (${result.device.remoteId})',
|
|
||||||
);
|
|
||||||
debugPrint(' RSSI: ${result.rssi}');
|
|
||||||
debugPrint(' Service UUIDs: ${result.advertisementData.serviceUuids}');
|
|
||||||
|
|
||||||
if (result.advertisementData.serviceUuids.contains(
|
|
||||||
Guid(MeshCoreConstants.bleServiceUuid),
|
|
||||||
)) {
|
|
||||||
deviceCount++;
|
|
||||||
debugPrint(' ✅ MeshCore device found! Total: $deviceCount');
|
|
||||||
yield result;
|
|
||||||
} else {
|
|
||||||
debugPrint(' ❌ Not a MeshCore device (service UUID mismatch)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
debugPrint('🏁 [BLE] Scan completed. Found $deviceCount MeshCore devices');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [BLE] Scan error: $e');
|
|
||||||
onError?.call('Scan error: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connect to a MeshCore device
|
|
||||||
Future<bool> connect(BluetoothDevice device) async {
|
|
||||||
try {
|
|
||||||
debugPrint(
|
|
||||||
'🔵 [BLE] Starting connection to device: ${device.platformName} (${device.remoteId})',
|
|
||||||
);
|
|
||||||
_device = device;
|
|
||||||
|
|
||||||
// Connect to device
|
|
||||||
debugPrint('🔵 [BLE] Calling device.connect() with 15s timeout...');
|
|
||||||
await device.connect(
|
|
||||||
license: License.free,
|
|
||||||
timeout: const Duration(seconds: 15),
|
|
||||||
mtu: 512,
|
|
||||||
);
|
|
||||||
debugPrint('✅ [BLE] Device connected successfully');
|
|
||||||
|
|
||||||
// Discover services
|
|
||||||
debugPrint('🔵 [BLE] Discovering services...');
|
|
||||||
final services = await device.discoverServices();
|
|
||||||
debugPrint('✅ [BLE] Found ${services.length} services');
|
|
||||||
|
|
||||||
// Log all discovered services for debugging
|
|
||||||
for (final service in services) {
|
|
||||||
debugPrint(' 📋 Service: ${service.uuid}');
|
|
||||||
for (final char in service.characteristics) {
|
|
||||||
debugPrint(' - Characteristic: ${char.uuid}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find MeshCore service
|
|
||||||
debugPrint(
|
|
||||||
'🔵 [BLE] Looking for MeshCore service: ${MeshCoreConstants.bleServiceUuid}',
|
|
||||||
);
|
|
||||||
BluetoothService? meshCoreService;
|
|
||||||
for (final service in services) {
|
|
||||||
if (service.uuid.toString().toLowerCase() ==
|
|
||||||
MeshCoreConstants.bleServiceUuid.toLowerCase()) {
|
|
||||||
meshCoreService = service;
|
|
||||||
debugPrint('✅ [BLE] Found MeshCore service');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (meshCoreService == null) {
|
|
||||||
debugPrint('❌ [BLE] MeshCore service not found!');
|
|
||||||
throw Exception('MeshCore service not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find RX and TX characteristics
|
|
||||||
debugPrint('🔵 [BLE] Looking for RX and TX characteristics...');
|
|
||||||
debugPrint(' RX UUID: ${MeshCoreConstants.bleCharacteristicRxUuid}');
|
|
||||||
debugPrint(' TX UUID: ${MeshCoreConstants.bleCharacteristicTxUuid}');
|
|
||||||
|
|
||||||
for (final characteristic in meshCoreService.characteristics) {
|
|
||||||
final uuid = characteristic.uuid.toString().toLowerCase();
|
|
||||||
debugPrint(' 📋 Checking characteristic: $uuid');
|
|
||||||
|
|
||||||
if (uuid == MeshCoreConstants.bleCharacteristicRxUuid.toLowerCase()) {
|
|
||||||
_rxCharacteristic = characteristic;
|
|
||||||
debugPrint(' ✅ Found RX characteristic');
|
|
||||||
} else if (uuid ==
|
|
||||||
MeshCoreConstants.bleCharacteristicTxUuid.toLowerCase()) {
|
|
||||||
_txCharacteristic = characteristic;
|
|
||||||
debugPrint(' ✅ Found TX characteristic');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_rxCharacteristic == null || _txCharacteristic == null) {
|
|
||||||
debugPrint('❌ [BLE] Required characteristics not found!');
|
|
||||||
debugPrint(' RX found: ${_rxCharacteristic != null}');
|
|
||||||
debugPrint(' TX found: ${_txCharacteristic != null}');
|
|
||||||
throw Exception('Required characteristics not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable notifications on TX characteristic
|
|
||||||
debugPrint('🔵 [BLE] Enabling notifications on TX characteristic...');
|
|
||||||
await _txCharacteristic!.setNotifyValue(true);
|
|
||||||
debugPrint('✅ [BLE] Notifications enabled');
|
|
||||||
|
|
||||||
_isConnected = true;
|
|
||||||
_reconnectionAttempt =
|
|
||||||
0; // Reset reconnection counter on successful connection
|
|
||||||
debugPrint('🔵 [BLE] Notifying connection state change: connected');
|
|
||||||
onConnectionStateChanged?.call(true);
|
|
||||||
|
|
||||||
// Monitor connection state for automatic reconnection
|
|
||||||
_setupConnectionMonitoring();
|
|
||||||
|
|
||||||
// Start RSSI monitoring
|
|
||||||
_startRssiMonitoring();
|
|
||||||
|
|
||||||
debugPrint('✅✅✅ [BLE] Connection completed successfully!');
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌❌❌ [BLE] Connection failed: $e');
|
|
||||||
debugPrint('Stack trace: ${StackTrace.current}');
|
|
||||||
onError?.call('Connection error: $e');
|
|
||||||
_isConnected = false;
|
|
||||||
onConnectionStateChanged?.call(false);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disconnect from device
|
|
||||||
Future<void> disconnect() async {
|
|
||||||
try {
|
|
||||||
debugPrint('🔴 [BLE] Disconnect requested by user');
|
|
||||||
// Disable reconnection before disconnecting
|
|
||||||
_reconnectionEnabled = false;
|
|
||||||
_cancelReconnection();
|
|
||||||
_stopRssiMonitoring();
|
|
||||||
|
|
||||||
await _device?.disconnect();
|
|
||||||
_isConnected = false;
|
|
||||||
_device = null;
|
|
||||||
_rxCharacteristic = null;
|
|
||||||
_txCharacteristic = null;
|
|
||||||
onConnectionStateChanged?.call(false);
|
|
||||||
} catch (e) {
|
|
||||||
onError?.call('Disconnect error: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Setup connection monitoring for automatic reconnection
|
|
||||||
void _setupConnectionMonitoring() {
|
|
||||||
debugPrint(
|
|
||||||
'🔵 [BLE] Setting up connection monitoring for device: ${_device?.platformName}',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cancel any existing subscription
|
|
||||||
_connectionStateSubscription?.cancel();
|
|
||||||
|
|
||||||
// Monitor connection state changes
|
|
||||||
_connectionStateSubscription = _device?.connectionState.listen((state) {
|
|
||||||
debugPrint('🔔 [BLE] Connection state changed: $state');
|
|
||||||
|
|
||||||
if (state == BluetoothConnectionState.disconnected) {
|
|
||||||
debugPrint('⚠️ [BLE] Device disconnected unexpectedly!');
|
|
||||||
_isConnected = false;
|
|
||||||
onConnectionStateChanged?.call(false);
|
|
||||||
|
|
||||||
// Attempt automatic reconnection if enabled
|
|
||||||
if (_reconnectionEnabled && !_isReconnecting) {
|
|
||||||
debugPrint('🔄 [BLE] Starting automatic reconnection...');
|
|
||||||
_attemptReconnection();
|
|
||||||
}
|
|
||||||
} else if (state == BluetoothConnectionState.connected) {
|
|
||||||
debugPrint('✅ [BLE] Device connected');
|
|
||||||
_isConnected = true;
|
|
||||||
_reconnectionAttempt = 0;
|
|
||||||
_isReconnecting = false;
|
|
||||||
onConnectionStateChanged?.call(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempt to reconnect to the device
|
|
||||||
Future<void> _attemptReconnection() async {
|
|
||||||
if (_device == null || _isReconnecting || !_reconnectionEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_isReconnecting = true;
|
|
||||||
_reconnectionAttempt++;
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'🔄 [BLE] Reconnection attempt $_reconnectionAttempt of $_maxReconnectionAttempts',
|
|
||||||
);
|
|
||||||
onReconnectionAttempt?.call(_reconnectionAttempt, _maxReconnectionAttempts);
|
|
||||||
|
|
||||||
if (_reconnectionAttempt > _maxReconnectionAttempts) {
|
|
||||||
debugPrint(
|
|
||||||
'❌ [BLE] Max reconnection attempts reached after ~15 minutes. Giving up.',
|
|
||||||
);
|
|
||||||
_isReconnecting = false;
|
|
||||||
onError?.call(
|
|
||||||
'Connection lost. Unable to reconnect after 15 minutes ($_maxReconnectionAttempts attempts).',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate delay with exponential backoff (uses last delay for attempts beyond array length)
|
|
||||||
final delayIndex = (_reconnectionAttempt - 1).clamp(
|
|
||||||
0,
|
|
||||||
_reconnectionDelaysMs.length - 1,
|
|
||||||
);
|
|
||||||
final delayMs = _reconnectionDelaysMs[delayIndex];
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'🔄 [BLE] Waiting ${(delayMs / 1000).toStringAsFixed(0)}s before reconnection attempt $_reconnectionAttempt...',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait before attempting reconnection
|
|
||||||
_reconnectionTimer = Timer(Duration(milliseconds: delayMs), () async {
|
|
||||||
if (!_reconnectionEnabled) {
|
|
||||||
debugPrint('🔄 [BLE] Reconnection cancelled by user');
|
|
||||||
_isReconnecting = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
debugPrint('🔄 [BLE] Attempting to reconnect...');
|
|
||||||
|
|
||||||
// Try to reconnect
|
|
||||||
final success = await connect(_device!);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
debugPrint('✅ [BLE] Reconnection successful!');
|
|
||||||
_isReconnecting = false;
|
|
||||||
_reconnectionAttempt = 0;
|
|
||||||
} else {
|
|
||||||
debugPrint('❌ [BLE] Reconnection attempt $_reconnectionAttempt failed');
|
|
||||||
_isReconnecting = false;
|
|
||||||
|
|
||||||
// Try again if we haven't reached max attempts
|
|
||||||
if (_reconnectionAttempt < _maxReconnectionAttempts) {
|
|
||||||
_attemptReconnection();
|
|
||||||
} else {
|
|
||||||
onError?.call(
|
|
||||||
'Connection lost. Unable to reconnect after 15 minutes ($_maxReconnectionAttempts attempts).',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [BLE] Reconnection attempt $_reconnectionAttempt error: $e');
|
|
||||||
_isReconnecting = false;
|
|
||||||
|
|
||||||
// Try again if we haven't reached max attempts
|
|
||||||
if (_reconnectionAttempt < _maxReconnectionAttempts) {
|
|
||||||
_attemptReconnection();
|
|
||||||
} else {
|
|
||||||
onError?.call(
|
|
||||||
'Connection lost. Unable to reconnect after 15 minutes: $e',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel ongoing reconnection attempts
|
|
||||||
void _cancelReconnection() {
|
|
||||||
debugPrint('🔴 [BLE] Cancelling reconnection attempts');
|
|
||||||
_reconnectionTimer?.cancel();
|
|
||||||
_reconnectionTimer = null;
|
|
||||||
_isReconnecting = false;
|
|
||||||
_reconnectionAttempt = 0;
|
|
||||||
_connectionStateSubscription?.cancel();
|
|
||||||
_connectionStateSubscription = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable automatic reconnection (useful after user manually disconnects)
|
|
||||||
void enableReconnection() {
|
|
||||||
debugPrint('🔵 [BLE] Re-enabling automatic reconnection');
|
|
||||||
_reconnectionEnabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start monitoring RSSI in the background
|
|
||||||
void _startRssiMonitoring() {
|
|
||||||
debugPrint('📡 [BLE] Starting RSSI monitoring (every 5 seconds)');
|
|
||||||
_stopRssiMonitoring(); // Cancel any existing timer
|
|
||||||
|
|
||||||
_rssiTimer = Timer.periodic(const Duration(seconds: 5), (timer) async {
|
|
||||||
if (_device != null && _isConnected) {
|
|
||||||
try {
|
|
||||||
final rssi = await _device!.readRssi();
|
|
||||||
if (_lastRssi != rssi) {
|
|
||||||
_lastRssi = rssi;
|
|
||||||
onRssiUpdate?.call(rssi);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [BLE] Failed to read RSSI: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop RSSI monitoring
|
|
||||||
void _stopRssiMonitoring() {
|
|
||||||
_rssiTimer?.cancel();
|
|
||||||
_rssiTimer = null;
|
|
||||||
_lastRssi = null;
|
|
||||||
debugPrint('📡 [BLE] RSSI monitoring stopped');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispose resources
|
|
||||||
void dispose() {
|
|
||||||
debugPrint('🔴 [BLE] Disposing BLE connection manager');
|
|
||||||
_cancelReconnection();
|
|
||||||
_stopRssiMonitoring();
|
|
||||||
_device = null;
|
|
||||||
_rxCharacteristic = null;
|
|
||||||
_txCharacteristic = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
/// Buffer writer for creating MeshCore protocol binary data
|
|
||||||
class BufferWriter {
|
|
||||||
final List<int> _buffer = [];
|
|
||||||
|
|
||||||
/// Get current buffer length
|
|
||||||
int get length => _buffer.length;
|
|
||||||
|
|
||||||
/// Write a single byte (uint8)
|
|
||||||
void writeByte(int value) {
|
|
||||||
if (value < 0 || value > 255) {
|
|
||||||
throw ArgumentError('Byte value must be between 0 and 255');
|
|
||||||
}
|
|
||||||
_buffer.add(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write a signed byte (int8)
|
|
||||||
void writeInt8(int value) {
|
|
||||||
if (value < -128 || value > 127) {
|
|
||||||
throw ArgumentError('Int8 value must be between -128 and 127');
|
|
||||||
}
|
|
||||||
_buffer.add(value < 0 ? value + 256 : value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write unsigned 16-bit integer (little-endian)
|
|
||||||
void writeUInt16LE(int value) {
|
|
||||||
if (value < 0 || value > 65535) {
|
|
||||||
throw ArgumentError('UInt16 value must be between 0 and 65535');
|
|
||||||
}
|
|
||||||
_buffer.add(value & 0xFF);
|
|
||||||
_buffer.add((value >> 8) & 0xFF);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write signed 16-bit integer (little-endian)
|
|
||||||
void writeInt16LE(int value) {
|
|
||||||
if (value < -32768 || value > 32767) {
|
|
||||||
throw ArgumentError('Int16 value must be between -32768 and 32767');
|
|
||||||
}
|
|
||||||
final unsigned = value < 0 ? value + 65536 : value;
|
|
||||||
writeUInt16LE(unsigned);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write unsigned 32-bit integer (little-endian)
|
|
||||||
void writeUInt32LE(int value) {
|
|
||||||
if (value < 0 || value > 4294967295) {
|
|
||||||
throw ArgumentError('UInt32 value must be between 0 and 4294967295');
|
|
||||||
}
|
|
||||||
_buffer.add(value & 0xFF);
|
|
||||||
_buffer.add((value >> 8) & 0xFF);
|
|
||||||
_buffer.add((value >> 16) & 0xFF);
|
|
||||||
_buffer.add((value >> 24) & 0xFF);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write signed 32-bit integer (little-endian)
|
|
||||||
void writeInt32LE(int value) {
|
|
||||||
if (value < -2147483648 || value > 2147483647) {
|
|
||||||
throw ArgumentError('Int32 value must be between -2147483648 and 2147483647');
|
|
||||||
}
|
|
||||||
final unsigned = value < 0 ? value + 4294967296 : value;
|
|
||||||
writeUInt32LE(unsigned);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write bytes from Uint8List
|
|
||||||
void writeBytes(Uint8List bytes) {
|
|
||||||
_buffer.addAll(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write bytes from `List<int>`
|
|
||||||
void writeBytesFromList(List<int> bytes) {
|
|
||||||
_buffer.addAll(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write null-terminated string (C-string) with fixed length
|
|
||||||
/// Pads with zeros if string is shorter than maxLength
|
|
||||||
void writeCString(String str, int maxLength) {
|
|
||||||
final bytes = utf8.encode(str);
|
|
||||||
|
|
||||||
// Ensure we don't exceed max length
|
|
||||||
final length = bytes.length < maxLength ? bytes.length : maxLength;
|
|
||||||
|
|
||||||
// Write string bytes
|
|
||||||
for (int i = 0; i < length; i++) {
|
|
||||||
_buffer.add(bytes[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pad with zeros
|
|
||||||
for (int i = length; i < maxLength; i++) {
|
|
||||||
_buffer.add(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write length-prefixed string
|
|
||||||
void writeString(String str) {
|
|
||||||
final bytes = utf8.encode(str);
|
|
||||||
_buffer.addAll(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write string with length prefix (1 byte)
|
|
||||||
void writeLengthPrefixedString(String str) {
|
|
||||||
final bytes = utf8.encode(str);
|
|
||||||
if (bytes.length > 255) {
|
|
||||||
throw ArgumentError('String too long for length-prefixed format (max 255 bytes)');
|
|
||||||
}
|
|
||||||
writeByte(bytes.length);
|
|
||||||
_buffer.addAll(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get buffer as Uint8List
|
|
||||||
Uint8List toBytes() {
|
|
||||||
return Uint8List.fromList(_buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear the buffer
|
|
||||||
void clear() {
|
|
||||||
_buffer.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get buffer as hex string (for debugging)
|
|
||||||
String toHexString() {
|
|
||||||
return _buffer.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return 'BufferWriter(length: $length, hex: ${toHexString()})';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../models/contact_telemetry.dart';
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
import 'buffer_reader.dart';
|
|
||||||
import 'meshcore_constants.dart';
|
|
||||||
|
|
||||||
/// Cayenne LPP (Low Power Payload) data parser
|
/// Cayenne LPP (Low Power Payload) data parser
|
||||||
/// Used for decoding telemetry sensor data from MeshCore devices
|
/// Used for decoding telemetry sensor data from MeshCore devices
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/contact_telemetry.dart';
|
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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
|
/// Centralized location tracking service for MeshCore SAR
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1,748 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
|
||||||
import '../models/contact.dart';
|
|
||||||
import '../models/message.dart';
|
|
||||||
import '../models/ble_packet_log.dart';
|
|
||||||
import 'ble/ble_connection_manager.dart';
|
|
||||||
import 'ble/ble_command_sender.dart';
|
|
||||||
import 'ble/ble_response_handler.dart';
|
|
||||||
import 'protocol/frame_builder.dart';
|
|
||||||
import 'meshcore_constants.dart';
|
|
||||||
|
|
||||||
/// Callback types for MeshCore events
|
|
||||||
typedef OnContactCallback = void Function(Contact contact);
|
|
||||||
typedef OnContactsCompleteCallback = void Function(List<Contact> contacts);
|
|
||||||
typedef OnMessageCallback = void Function(Message message);
|
|
||||||
typedef OnTelemetryCallback =
|
|
||||||
void Function(Uint8List publicKey, Uint8List lppData);
|
|
||||||
typedef OnSelfInfoCallback = void Function(Map<String, dynamic> selfInfo);
|
|
||||||
typedef OnDeviceInfoCallback = void Function(Map<String, dynamic> deviceInfo);
|
|
||||||
typedef OnNoMoreMessagesCallback = void Function();
|
|
||||||
typedef OnMessageWaitingCallback = void Function();
|
|
||||||
typedef OnLoginSuccessCallback =
|
|
||||||
void Function(
|
|
||||||
Uint8List publicKeyPrefix,
|
|
||||||
int permissions,
|
|
||||||
bool isAdmin,
|
|
||||||
int tag,
|
|
||||||
);
|
|
||||||
typedef OnLoginFailCallback = void Function(Uint8List publicKeyPrefix);
|
|
||||||
typedef OnAdvertReceivedCallback = void Function(Uint8List publicKey);
|
|
||||||
typedef OnPathUpdatedCallback = void Function(Uint8List publicKey);
|
|
||||||
typedef OnMessageSentCallback = void Function(
|
|
||||||
int expectedAckTag,
|
|
||||||
int suggestedTimeoutMs,
|
|
||||||
bool isFloodMode,
|
|
||||||
Uint8List? contactPublicKey,
|
|
||||||
);
|
|
||||||
typedef OnMessageDeliveredCallback =
|
|
||||||
void Function(int ackCode, int roundTripTimeMs);
|
|
||||||
typedef OnMessageEchoDetectedCallback =
|
|
||||||
void Function(String messageId, int echoCount, int snrRaw, int rssiDbm);
|
|
||||||
typedef OnStatusResponseCallback =
|
|
||||||
void Function(Uint8List publicKeyPrefix, Uint8List statusData);
|
|
||||||
typedef OnBinaryResponseCallback =
|
|
||||||
void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData);
|
|
||||||
typedef OnBatteryAndStorageCallback =
|
|
||||||
void Function(int millivolts, int? usedKb, int? totalKb);
|
|
||||||
typedef OnErrorCallback = void Function(String error, {int? errorCode});
|
|
||||||
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
|
|
||||||
typedef OnChannelInfoCallback =
|
|
||||||
void Function(int channelIdx, String channelName, Uint8List secret, int? flags);
|
|
||||||
typedef OnConnectionStateCallback = void Function(bool isConnected);
|
|
||||||
typedef OnReconnectionAttemptCallback =
|
|
||||||
void Function(int attemptNumber, int maxAttempts);
|
|
||||||
typedef OnRssiUpdateCallback = void Function(int rssi);
|
|
||||||
|
|
||||||
/// MeshCore BLE Service - coordinates BLE communication components
|
|
||||||
class MeshCoreBleService {
|
|
||||||
// Component instances
|
|
||||||
final BleConnectionManager _connectionManager = BleConnectionManager();
|
|
||||||
final BleCommandSender _commandSender = BleCommandSender();
|
|
||||||
final BleResponseHandler _responseHandler = BleResponseHandler();
|
|
||||||
|
|
||||||
// Keepalive timer for iOS background mode
|
|
||||||
Timer? _keepaliveTimer;
|
|
||||||
static const Duration _keepaliveInterval = Duration(seconds: 20);
|
|
||||||
|
|
||||||
// Event callbacks
|
|
||||||
OnConnectionStateCallback? onConnectionStateChanged;
|
|
||||||
OnReconnectionAttemptCallback? onReconnectionAttempt;
|
|
||||||
OnRssiUpdateCallback? onRssiUpdate;
|
|
||||||
OnContactCallback? onContactReceived;
|
|
||||||
OnContactsCompleteCallback? onContactsComplete;
|
|
||||||
OnMessageCallback? onMessageReceived;
|
|
||||||
OnTelemetryCallback? onTelemetryReceived;
|
|
||||||
OnSelfInfoCallback? onSelfInfoReceived;
|
|
||||||
OnDeviceInfoCallback? onDeviceInfoReceived;
|
|
||||||
OnNoMoreMessagesCallback? onNoMoreMessages;
|
|
||||||
OnMessageWaitingCallback? onMessageWaiting;
|
|
||||||
OnLoginSuccessCallback? onLoginSuccess;
|
|
||||||
OnLoginFailCallback? onLoginFail;
|
|
||||||
OnAdvertReceivedCallback? onAdvertReceived;
|
|
||||||
OnPathUpdatedCallback? onPathUpdated;
|
|
||||||
OnMessageSentCallback? onMessageSent;
|
|
||||||
OnMessageDeliveredCallback? onMessageDelivered;
|
|
||||||
OnMessageEchoDetectedCallback? onMessageEchoDetected;
|
|
||||||
OnStatusResponseCallback? onStatusResponse;
|
|
||||||
OnBinaryResponseCallback? onBinaryResponse;
|
|
||||||
OnBatteryAndStorageCallback? onBatteryAndStorage;
|
|
||||||
OnErrorCallback? onError;
|
|
||||||
OnContactNotFoundCallback? onContactNotFound;
|
|
||||||
OnChannelInfoCallback? onChannelInfoReceived;
|
|
||||||
void Function(Uint8List publicKey)? onContactDeleted;
|
|
||||||
VoidCallback? onContactsFull;
|
|
||||||
|
|
||||||
// Activity callbacks (for blinking indicators)
|
|
||||||
VoidCallback? onRxActivity;
|
|
||||||
VoidCallback? onTxActivity;
|
|
||||||
|
|
||||||
// Constructor
|
|
||||||
MeshCoreBleService() {
|
|
||||||
_setupCallbacks();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup callbacks between components
|
|
||||||
void _setupCallbacks() {
|
|
||||||
// Connection manager callbacks
|
|
||||||
_connectionManager.onConnectionStateChanged = (isConnected) {
|
|
||||||
if (isConnected) {
|
|
||||||
_startKeepalive();
|
|
||||||
} else {
|
|
||||||
_stopKeepalive();
|
|
||||||
}
|
|
||||||
onConnectionStateChanged?.call(isConnected);
|
|
||||||
};
|
|
||||||
_connectionManager.onError = (error) {
|
|
||||||
onError?.call(error);
|
|
||||||
};
|
|
||||||
_connectionManager.onReconnectionAttempt = (attemptNumber, maxAttempts) {
|
|
||||||
debugPrint(
|
|
||||||
'🔄 [Service] Reconnection attempt $attemptNumber/$maxAttempts',
|
|
||||||
);
|
|
||||||
onReconnectionAttempt?.call(attemptNumber, maxAttempts);
|
|
||||||
};
|
|
||||||
_connectionManager.onRssiUpdate = (rssi) {
|
|
||||||
onRssiUpdate?.call(rssi);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Command sender callbacks
|
|
||||||
_commandSender.onError = (error) {
|
|
||||||
onError?.call(error);
|
|
||||||
};
|
|
||||||
_commandSender.onTxActivity = () {
|
|
||||||
onTxActivity?.call();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Response handler callbacks
|
|
||||||
_responseHandler.onContactReceived = (contact) {
|
|
||||||
debugPrint('🔔 [BleService] onContactReceived - "${contact.advName}" - forwarding to ConnectionProvider');
|
|
||||||
onContactReceived?.call(contact);
|
|
||||||
};
|
|
||||||
_responseHandler.onContactsComplete = (contacts) {
|
|
||||||
debugPrint('🔔 [BleService] onContactsComplete - ${contacts.length} contacts - forwarding to ConnectionProvider');
|
|
||||||
onContactsComplete?.call(contacts);
|
|
||||||
};
|
|
||||||
_responseHandler.onMessageReceived = (message) {
|
|
||||||
debugPrint('🔔 [BleService] onMessageReceived - forwarding to ConnectionProvider');
|
|
||||||
onMessageReceived?.call(message);
|
|
||||||
};
|
|
||||||
_responseHandler.onTelemetryReceived = (publicKey, lppData) {
|
|
||||||
debugPrint('🔔 [BleService] onTelemetryReceived - ${lppData.length} bytes - forwarding to ConnectionProvider');
|
|
||||||
onTelemetryReceived?.call(publicKey, lppData);
|
|
||||||
};
|
|
||||||
_responseHandler.onSelfInfoReceived = (selfInfo) {
|
|
||||||
// Extract our node hash (first byte of public key) for echo detection
|
|
||||||
if (selfInfo['publicKey'] != null) {
|
|
||||||
final publicKey = selfInfo['publicKey'] as Uint8List;
|
|
||||||
if (publicKey.isNotEmpty) {
|
|
||||||
_responseHandler.setOurNodeHash(publicKey[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onSelfInfoReceived?.call(selfInfo);
|
|
||||||
};
|
|
||||||
_responseHandler.onDeviceInfoReceived = (deviceInfo) {
|
|
||||||
onDeviceInfoReceived?.call(deviceInfo);
|
|
||||||
};
|
|
||||||
_responseHandler.onNoMoreMessages = () {
|
|
||||||
onNoMoreMessages?.call();
|
|
||||||
};
|
|
||||||
_responseHandler.onMessageWaiting = () {
|
|
||||||
onMessageWaiting?.call();
|
|
||||||
};
|
|
||||||
_responseHandler.onLoginSuccess =
|
|
||||||
(publicKeyPrefix, permissions, isAdmin, tag) {
|
|
||||||
onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag);
|
|
||||||
};
|
|
||||||
_responseHandler.onLoginFail = (publicKeyPrefix) {
|
|
||||||
onLoginFail?.call(publicKeyPrefix);
|
|
||||||
};
|
|
||||||
_responseHandler.onAdvertReceived = (publicKey) {
|
|
||||||
debugPrint('🔔 [BleService] onAdvertReceived - forwarding to ConnectionProvider');
|
|
||||||
onAdvertReceived?.call(publicKey);
|
|
||||||
};
|
|
||||||
_responseHandler.onPathUpdated = (publicKey) {
|
|
||||||
debugPrint('🔔 [BleService] onPathUpdated - forwarding to ConnectionProvider');
|
|
||||||
onPathUpdated?.call(publicKey);
|
|
||||||
};
|
|
||||||
_responseHandler.onMessageSent =
|
|
||||||
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
|
|
||||||
onMessageSent?.call(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey);
|
|
||||||
};
|
|
||||||
_responseHandler.onMessageDelivered = (ackCode, roundTripTimeMs) {
|
|
||||||
onMessageDelivered?.call(ackCode, roundTripTimeMs);
|
|
||||||
};
|
|
||||||
_responseHandler.onMessageEchoDetected =
|
|
||||||
(messageId, echoCount, snrRaw, rssiDbm) {
|
|
||||||
onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm);
|
|
||||||
};
|
|
||||||
_responseHandler.onStatusResponse = (publicKeyPrefix, statusData) {
|
|
||||||
onStatusResponse?.call(publicKeyPrefix, statusData);
|
|
||||||
};
|
|
||||||
_responseHandler.onBinaryResponse = (publicKeyPrefix, tag, responseData) {
|
|
||||||
onBinaryResponse?.call(publicKeyPrefix, tag, responseData);
|
|
||||||
};
|
|
||||||
_responseHandler.onBatteryAndStorage = (millivolts, usedKb, totalKb) {
|
|
||||||
onBatteryAndStorage?.call(millivolts, usedKb, totalKb);
|
|
||||||
};
|
|
||||||
_responseHandler.onError = (error, {int? errorCode}) {
|
|
||||||
onError?.call(error, errorCode: errorCode);
|
|
||||||
};
|
|
||||||
_responseHandler.onContactNotFound = (contactPublicKey) {
|
|
||||||
onContactNotFound?.call(contactPublicKey);
|
|
||||||
};
|
|
||||||
_responseHandler.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) {
|
|
||||||
onChannelInfoReceived?.call(channelIdx, channelName, secret, flags);
|
|
||||||
};
|
|
||||||
_responseHandler.onContactDeleted = (publicKey) {
|
|
||||||
onContactDeleted?.call(publicKey);
|
|
||||||
};
|
|
||||||
_responseHandler.onContactsFull = () {
|
|
||||||
onContactsFull?.call();
|
|
||||||
};
|
|
||||||
_responseHandler.onRxActivity = () {
|
|
||||||
onRxActivity?.call();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
bool get isConnected => _connectionManager.isConnected;
|
|
||||||
bool get isReconnecting => _connectionManager.isReconnecting;
|
|
||||||
int get reconnectionAttempt => _connectionManager.reconnectionAttempt;
|
|
||||||
int get maxReconnectionAttempts => _connectionManager.maxReconnectionAttempts;
|
|
||||||
int get rxPacketCount => _responseHandler.rxPacketCount;
|
|
||||||
int get txPacketCount => _commandSender.txPacketCount;
|
|
||||||
List<BlePacketLog> get packetLogs {
|
|
||||||
// Merge logs from both sender and handler
|
|
||||||
final allLogs = [
|
|
||||||
..._commandSender.packetLogs,
|
|
||||||
..._responseHandler.packetLogs,
|
|
||||||
];
|
|
||||||
allLogs.sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
|
||||||
return allLogs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scan for MeshCore devices
|
|
||||||
Stream<ScanResult> scanForDevices({
|
|
||||||
Duration timeout = const Duration(seconds: 10),
|
|
||||||
}) {
|
|
||||||
return _connectionManager.scanForDevices(timeout: timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connect to a MeshCore device
|
|
||||||
Future<bool> connect(BluetoothDevice device) async {
|
|
||||||
final success = await _connectionManager.connect(device);
|
|
||||||
if (success) {
|
|
||||||
try {
|
|
||||||
// Setup command sender with RX characteristic
|
|
||||||
_commandSender.setRxCharacteristic(_connectionManager.rxCharacteristic);
|
|
||||||
|
|
||||||
// Wire up command queue between sender and response handler
|
|
||||||
_responseHandler.setCommandQueue(_commandSender.commandQueue);
|
|
||||||
|
|
||||||
// Setup response handler with TX characteristic
|
|
||||||
if (_connectionManager.txCharacteristic != null) {
|
|
||||||
_responseHandler.subscribeToNotifications(
|
|
||||||
_connectionManager.txCharacteristic!,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send initial device query and wait for responses
|
|
||||||
await _sendDeviceQuery();
|
|
||||||
|
|
||||||
debugPrint('✅ [Service] Device initialization complete');
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [Service] Device initialization failed: $e');
|
|
||||||
// Disconnect on initialization failure
|
|
||||||
await disconnect();
|
|
||||||
onError?.call('Device initialization failed: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disconnect from device
|
|
||||||
Future<void> disconnect() async {
|
|
||||||
await _connectionManager.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send initial device query and sync clock
|
|
||||||
Future<void> _sendDeviceQuery() async {
|
|
||||||
// STEP 1: Send device query FIRST to get device capabilities
|
|
||||||
// This is the first command to send per protocol documentation
|
|
||||||
debugPrint(
|
|
||||||
'🔍 [Service] Querying device information (CMD_DEVICE_QUERY)...',
|
|
||||||
);
|
|
||||||
final deviceInfo = await _commandSender
|
|
||||||
.writeDataAndWaitForResponse<Map<String, dynamic>>(
|
|
||||||
FrameBuilder.buildDeviceQuery(),
|
|
||||||
MeshCoreConstants.respDeviceInfo,
|
|
||||||
);
|
|
||||||
debugPrint(
|
|
||||||
'✅ [Service] Device info received: firmware=${deviceInfo['firmwareVersion']}',
|
|
||||||
);
|
|
||||||
|
|
||||||
// STEP 2: Send app start to initialize the app session
|
|
||||||
// This is the first command after connection per protocol documentation
|
|
||||||
debugPrint('🚀 [Service] Sending app start (CMD_APP_START)...');
|
|
||||||
await _commandSender.writeDataAndWaitForResponse<Map<String, dynamic>>(
|
|
||||||
FrameBuilder.buildAppStart(),
|
|
||||||
MeshCoreConstants.respSelfInfo,
|
|
||||||
);
|
|
||||||
debugPrint('✅ [Service] Self info received: node initialized');
|
|
||||||
|
|
||||||
// STEP 3: Set device clock AFTER initialization
|
|
||||||
// This ensures the device has correct timestamps for all subsequent operations
|
|
||||||
// Note: This command does not return an ACK, so we use writeData (fire-and-forget)
|
|
||||||
debugPrint('⏰ [Service] Setting device clock (CMD_SET_DEVICE_TIME)...');
|
|
||||||
await _commandSender.writeData(FrameBuilder.buildSetDeviceTime());
|
|
||||||
debugPrint('✅ [Service] Device clock sent (no ACK expected)');
|
|
||||||
|
|
||||||
// STEP 4: Sync any waiting messages immediately after connection
|
|
||||||
// This ensures we receive messages that arrived while disconnected
|
|
||||||
debugPrint('📬 [Service] Syncing messages (CMD_SYNC_NEXT_MESSAGE)...');
|
|
||||||
await syncNextMessage();
|
|
||||||
debugPrint('✅ [Service] Message sync initiated');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refresh device info (public method)
|
|
||||||
Future<void> refreshDeviceInfo() async {
|
|
||||||
await _sendDeviceQuery();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get contacts from device
|
|
||||||
Future<void> getContacts() async {
|
|
||||||
await _commandSender.writeData(FrameBuilder.buildGetContacts());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a single contact by public key from device
|
|
||||||
///
|
|
||||||
/// This is more efficient than getContacts() when you only need to refresh
|
|
||||||
/// one specific contact (e.g., after receiving an advertisement).
|
|
||||||
///
|
|
||||||
/// The contact will be delivered via the onContactReceived callback.
|
|
||||||
Future<void> getContactByKey(Uint8List publicKey) async {
|
|
||||||
debugPrint('🔍 [BLE] Requesting single contact by key:');
|
|
||||||
debugPrint(
|
|
||||||
' Public key prefix: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
|
|
||||||
);
|
|
||||||
await _commandSender.writeData(FrameBuilder.buildGetContactByKey(publicKey));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manually add or update a contact on the companion radio
|
|
||||||
Future<void> addOrUpdateContact(Contact contact) async {
|
|
||||||
debugPrint('📝 [BLE] Adding/updating contact on companion radio:');
|
|
||||||
debugPrint(' Name: ${contact.advName}');
|
|
||||||
debugPrint(
|
|
||||||
' Public key prefix: ${contact.publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
|
||||||
);
|
|
||||||
debugPrint(' Type: ${contact.type} (${contact.type.value})');
|
|
||||||
|
|
||||||
await _commandSender.writeData(FrameBuilder.buildAddUpdateContact(contact));
|
|
||||||
|
|
||||||
debugPrint('✅ [BLE] CMD_ADD_UPDATE_CONTACT sent');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send text message to contact (DM)
|
|
||||||
Future<void> sendTextMessage({
|
|
||||||
required Uint8List contactPublicKey,
|
|
||||||
required String text,
|
|
||||||
int textType = 0,
|
|
||||||
int attempt = 0,
|
|
||||||
}) async {
|
|
||||||
if (text.length > 160) {
|
|
||||||
throw ArgumentError('Text message exceeds 160 character limit');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track the last contact for auto-recovery if contact not found
|
|
||||||
_responseHandler.setLastContactPublicKey(contactPublicKey);
|
|
||||||
|
|
||||||
await _commandSender.writeData(
|
|
||||||
FrameBuilder.buildSendTxtMsg(
|
|
||||||
contactPublicKey: contactPublicKey,
|
|
||||||
text: text,
|
|
||||||
textType: textType,
|
|
||||||
attempt: attempt,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send flood-mode text message to channel
|
|
||||||
/// Track a sent channel message for echo detection
|
|
||||||
void trackSentChannelMessage(String messageId) {
|
|
||||||
debugPrint(
|
|
||||||
'🔵 [MeshCoreBleService] trackSentChannelMessage called for: $messageId',
|
|
||||||
);
|
|
||||||
_responseHandler.trackSentMessage(messageId, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a text message to a channel (flood-mode broadcast)
|
|
||||||
///
|
|
||||||
/// Channel messages are ephemeral and use flood routing (no ACKs).
|
|
||||||
/// Use channel 0 for the default public channel.
|
|
||||||
///
|
|
||||||
/// Note: Uses fire-and-forget mode since channel messages don't return
|
|
||||||
/// delivery confirmation (they're broadcast to all nodes).
|
|
||||||
Future<void> sendChannelMessage({
|
|
||||||
required int channelIdx,
|
|
||||||
required String text,
|
|
||||||
int textType = 0,
|
|
||||||
}) async {
|
|
||||||
if (text.length > 160) {
|
|
||||||
throw ArgumentError('Channel message too long (max ~160 characters)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channel messages use fire-and-forget (no ACK expected)
|
|
||||||
// The firmware responds with RESP_CODE_OK but we don't wait for it
|
|
||||||
await _commandSender.writeData(
|
|
||||||
FrameBuilder.buildSendChannelTxtMsg(
|
|
||||||
channelIdx: channelIdx,
|
|
||||||
text: text,
|
|
||||||
textType: textType,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request telemetry (GPS, battery) from contact
|
|
||||||
Future<void> requestTelemetry(
|
|
||||||
Uint8List contactPublicKey, {
|
|
||||||
bool zeroHop = false,
|
|
||||||
}) async {
|
|
||||||
await _commandSender.writeData(
|
|
||||||
FrameBuilder.buildSendTelemetryReq(contactPublicKey, zeroHop: zeroHop),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send binary request to contact
|
|
||||||
Future<void> sendBinaryRequest({
|
|
||||||
required Uint8List contactPublicKey,
|
|
||||||
required Uint8List requestData,
|
|
||||||
}) async {
|
|
||||||
await _commandSender.writeData(
|
|
||||||
FrameBuilder.buildSendBinaryReq(
|
|
||||||
contactPublicKey: contactPublicKey,
|
|
||||||
requestData: requestData,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get battery voltage and storage information
|
|
||||||
Future<void> getBatteryAndStorage() async {
|
|
||||||
await _commandSender.writeData(FrameBuilder.buildGetBatteryAndStorage());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Legacy method name for backward compatibility
|
|
||||||
@Deprecated('Use getBatteryAndStorage() instead')
|
|
||||||
Future<void> getBatteryVoltage() async {
|
|
||||||
await getBatteryAndStorage();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sync next message from device queue
|
|
||||||
Future<void> syncNextMessage() async {
|
|
||||||
await _commandSender.writeData(FrameBuilder.buildSyncNextMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get device time from companion radio
|
|
||||||
Future<void> getDeviceTime() async {
|
|
||||||
await _commandSender.writeData(FrameBuilder.buildGetDeviceTime());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set device time
|
|
||||||
Future<void> setDeviceTime() async {
|
|
||||||
await _commandSender.writeData(FrameBuilder.buildSetDeviceTime());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send self advertisement packet to mesh network
|
|
||||||
Future<void> sendSelfAdvert({bool floodMode = true}) async {
|
|
||||||
await _commandSender.writeData(
|
|
||||||
FrameBuilder.buildSendSelfAdvert(floodMode: floodMode),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set advertised name
|
|
||||||
Future<void> setAdvertName(String name) async {
|
|
||||||
await _commandSender.writeDataAndWaitForAck(
|
|
||||||
FrameBuilder.buildSetAdvertName(name),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set advertised latitude and longitude
|
|
||||||
Future<void> setAdvertLatLon({
|
|
||||||
required double latitude,
|
|
||||||
required double longitude,
|
|
||||||
}) async {
|
|
||||||
// This command updates device's advertised location
|
|
||||||
// Fire-and-forget - no ACK needed since actual broadcast happens via sendSelfAdvert
|
|
||||||
await _commandSender.writeData(
|
|
||||||
FrameBuilder.buildSetAdvertLatLon(
|
|
||||||
latitude: latitude,
|
|
||||||
longitude: longitude,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set radio parameters
|
|
||||||
Future<void> setRadioParams({
|
|
||||||
required int frequency,
|
|
||||||
required int bandwidth,
|
|
||||||
required int spreadingFactor,
|
|
||||||
required int codingRate,
|
|
||||||
}) async {
|
|
||||||
await _commandSender.writeDataAndWaitForAck(
|
|
||||||
FrameBuilder.buildSetRadioParams(
|
|
||||||
frequency: frequency,
|
|
||||||
bandwidth: bandwidth,
|
|
||||||
spreadingFactor: spreadingFactor,
|
|
||||||
codingRate: codingRate,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set transmit power
|
|
||||||
Future<void> setTxPower(int powerDbm) async {
|
|
||||||
await _commandSender.writeDataAndWaitForAck(
|
|
||||||
FrameBuilder.buildSetTxPower(powerDbm),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set other parameters
|
|
||||||
Future<void> setOtherParams({
|
|
||||||
required int manualAddContacts,
|
|
||||||
required int telemetryModes,
|
|
||||||
required int advertLocationPolicy,
|
|
||||||
int multiAcks = 0,
|
|
||||||
}) async {
|
|
||||||
await _commandSender.writeDataAndWaitForAck(
|
|
||||||
FrameBuilder.buildSetOtherParams(
|
|
||||||
manualAddContacts: manualAddContacts,
|
|
||||||
telemetryModes: telemetryModes,
|
|
||||||
advertLocationPolicy: advertLocationPolicy,
|
|
||||||
multiAcks: multiAcks,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send login request to room or repeater
|
|
||||||
Future<void> loginToRoom({
|
|
||||||
required Uint8List roomPublicKey,
|
|
||||||
required String password,
|
|
||||||
}) async {
|
|
||||||
if (password.length > 15) {
|
|
||||||
throw ArgumentError('Password exceeds 15 character limit');
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('🔐 [BLE] Preparing login request:');
|
|
||||||
debugPrint(
|
|
||||||
' Room public key prefix: ${roomPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
|
||||||
);
|
|
||||||
debugPrint(
|
|
||||||
' Password: ${"*" * password.length} (${password.length} chars)',
|
|
||||||
);
|
|
||||||
|
|
||||||
await _commandSender.writeData(
|
|
||||||
FrameBuilder.buildSendLogin(
|
|
||||||
roomPublicKey: roomPublicKey,
|
|
||||||
password: password,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send status request to repeater or sensor node
|
|
||||||
Future<void> sendStatusRequest(Uint8List contactPublicKey) async {
|
|
||||||
debugPrint('📊 [BLE] Preparing status request:');
|
|
||||||
debugPrint(
|
|
||||||
' Target node public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
|
||||||
);
|
|
||||||
|
|
||||||
await _commandSender.writeData(
|
|
||||||
FrameBuilder.buildSendStatusReq(contactPublicKey),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset path for a contact - forces next message to flood and re-learn route
|
|
||||||
Future<void> resetPath(Uint8List contactPublicKey) async {
|
|
||||||
debugPrint('🔄 [BLE] Resetting path for contact:');
|
|
||||||
debugPrint(
|
|
||||||
' Contact public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
|
||||||
);
|
|
||||||
|
|
||||||
await _commandSender.writeData(
|
|
||||||
FrameBuilder.buildResetPath(contactPublicKey),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a contact from the companion radio
|
|
||||||
Future<void> removeContact(Uint8List contactPublicKey) async {
|
|
||||||
debugPrint('🗑️ [BLE] Removing contact from companion radio:');
|
|
||||||
debugPrint(
|
|
||||||
' Public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
|
||||||
);
|
|
||||||
|
|
||||||
await _commandSender.writeData(
|
|
||||||
FrameBuilder.buildRemoveContact(contactPublicKey),
|
|
||||||
);
|
|
||||||
debugPrint('✅ [BLE] CMD_REMOVE_CONTACT sent');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get information for a specific channel
|
|
||||||
Future<void> getChannel(int channelIdx) async {
|
|
||||||
await _commandSender.writeData(FrameBuilder.buildGetChannel(channelIdx));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the name and secret for a specific channel
|
|
||||||
///
|
|
||||||
/// The secret must be exactly 16 bytes (128-bit encryption key).
|
|
||||||
/// For the default public channel (channel 0), use [MeshCoreConstants.defaultPublicChannelSecret].
|
|
||||||
///
|
|
||||||
/// Note: Some firmware versions don't send ACK for SET_CHANNEL, so we use
|
|
||||||
/// fire-and-forget and then verify with GET_CHANNEL.
|
|
||||||
Future<void> setChannel({
|
|
||||||
required int channelIdx,
|
|
||||||
required String channelName,
|
|
||||||
required List<int> secret,
|
|
||||||
}) async {
|
|
||||||
debugPrint('📻 [BLE] Setting channel:');
|
|
||||||
debugPrint(' Channel index: $channelIdx');
|
|
||||||
debugPrint(' Channel name: $channelName');
|
|
||||||
debugPrint(' Secret length: ${secret.length} bytes');
|
|
||||||
debugPrint(' Secret hex: ${secret.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
|
|
||||||
|
|
||||||
// Send SET_CHANNEL command (fire-and-forget, no ACK expected)
|
|
||||||
final setChannelData = FrameBuilder.buildSetChannel(
|
|
||||||
channelIdx: channelIdx,
|
|
||||||
channelName: channelName,
|
|
||||||
secret: secret,
|
|
||||||
);
|
|
||||||
debugPrint(' SET_CHANNEL data (${setChannelData.length} bytes): ${setChannelData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
|
|
||||||
|
|
||||||
await _commandSender.writeData(setChannelData);
|
|
||||||
debugPrint('✅ [BLE] CMD_SET_CHANNEL sent');
|
|
||||||
|
|
||||||
// Wait a bit for the device to process
|
|
||||||
await Future.delayed(const Duration(milliseconds: 200));
|
|
||||||
|
|
||||||
// Verify the channel was set by reading it back
|
|
||||||
debugPrint('🔍 [BLE] Verifying channel was set...');
|
|
||||||
await getChannel(channelIdx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a channel by clearing its slot
|
|
||||||
///
|
|
||||||
/// This removes the channel from the device by setting it to an empty name and zeroed secret.
|
|
||||||
/// The channel slot becomes available for reuse.
|
|
||||||
///
|
|
||||||
/// Note: Channel 0 (public channel) cannot be deleted.
|
|
||||||
Future<void> deleteChannel(int channelIdx) async {
|
|
||||||
if (channelIdx == 0) {
|
|
||||||
throw ArgumentError('Cannot delete channel 0 (public channel)');
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('🗑️ [BLE] Deleting channel $channelIdx...');
|
|
||||||
|
|
||||||
// Clear channel by setting empty name and zeroed secret
|
|
||||||
await setChannel(
|
|
||||||
channelIdx: channelIdx,
|
|
||||||
channelName: '',
|
|
||||||
secret: List.filled(16, 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
debugPrint('✅ [BLE] Channel $channelIdx deleted');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sync all channels from the device (channels 1-39)
|
|
||||||
/// Skips channel 0 (public channel) which is implicit and not stored on device
|
|
||||||
Future<void> syncAllChannels({int maxChannels = 40}) async {
|
|
||||||
debugPrint('📻 [Service] Syncing channels (1-${maxChannels - 1})...');
|
|
||||||
|
|
||||||
// Start from 1 to skip channel 0 (public channel)
|
|
||||||
// Channel 0 is implicit and handled separately via configurePublicChannel()
|
|
||||||
for (int i = 1; i < maxChannels; i++) {
|
|
||||||
await getChannel(i);
|
|
||||||
// Small delay to avoid overwhelming the device
|
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('✅ [Service] Channel sync complete');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear packet logs
|
|
||||||
void clearPacketLogs() {
|
|
||||||
_commandSender.clearPacketLogs();
|
|
||||||
_responseHandler.clearPacketLogs();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset packet counters
|
|
||||||
void resetCounters() {
|
|
||||||
_commandSender.resetCounter();
|
|
||||||
_responseHandler.resetCounter();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start keepalive timer for iOS background mode
|
|
||||||
/// Periodically syncs messages to keep BLE connection alive and check for new messages
|
|
||||||
/// This serves dual purpose: prevents iOS from killing idle BLE connections AND
|
|
||||||
/// provides fallback message sync when push notifications (PUSH_CODE_MSG_WAITING) don't trigger
|
|
||||||
void _startKeepalive() {
|
|
||||||
_stopKeepalive(); // Stop any existing timer
|
|
||||||
|
|
||||||
debugPrint('🔄 [BLE] Starting keepalive timer (${_keepaliveInterval.inSeconds}s interval)');
|
|
||||||
|
|
||||||
_keepaliveTimer = Timer.periodic(_keepaliveInterval, (timer) async {
|
|
||||||
if (!isConnected) {
|
|
||||||
debugPrint('⚠️ [BLE] Keepalive: Not connected, stopping timer');
|
|
||||||
_stopKeepalive();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Sync messages to keep connection alive AND check for new messages
|
|
||||||
// This is a fallback in case PUSH_CODE_MSG_WAITING doesn't fire
|
|
||||||
// If no messages waiting, device responds with RESP_CODE_NO_MORE_MSG
|
|
||||||
await syncNextMessage();
|
|
||||||
debugPrint('💚 [BLE] Keepalive: Connection maintained & messages synced');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [BLE] Keepalive error: $e');
|
|
||||||
// Don't stop timer on error - iOS might throttle commands temporarily
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop keepalive timer
|
|
||||||
void _stopKeepalive() {
|
|
||||||
if (_keepaliveTimer != null) {
|
|
||||||
debugPrint('🛑 [BLE] Stopping keepalive timer');
|
|
||||||
_keepaliveTimer?.cancel();
|
|
||||||
_keepaliveTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispose resources
|
|
||||||
void dispose() {
|
|
||||||
_stopKeepalive(); // Clean up keepalive timer
|
|
||||||
_connectionManager.dispose();
|
|
||||||
_commandSender.dispose();
|
|
||||||
_responseHandler.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
/// MeshCore BLE and Protocol Constants
|
|
||||||
class MeshCoreConstants {
|
|
||||||
// Supported protocol version (firmware uses this to decide V1 vs V3 message frames)
|
|
||||||
static const int supportedCompanionProtocolVersion = 3;
|
|
||||||
|
|
||||||
// BLE Service and Characteristic UUIDs
|
|
||||||
static const String bleServiceUuid =
|
|
||||||
'6E400001-B5A3-F393-E0A9-E50E24DCCA9E';
|
|
||||||
static const String bleCharacteristicRxUuid =
|
|
||||||
'6E400002-B5A3-F393-E0A9-E50E24DCCA9E'; // Write
|
|
||||||
static const String bleCharacteristicTxUuid =
|
|
||||||
'6E400003-B5A3-F393-E0A9-E50E24DCCA9E'; // Notify
|
|
||||||
|
|
||||||
// Command Codes (App -> Device)
|
|
||||||
static const int cmdAppStart = 1;
|
|
||||||
static const int cmdSendTxtMsg = 2;
|
|
||||||
static const int cmdSendChannelTxtMsg = 3;
|
|
||||||
static const int cmdGetContacts = 4;
|
|
||||||
static const int cmdGetDeviceTime = 5;
|
|
||||||
static const int cmdSetDeviceTime = 6;
|
|
||||||
static const int cmdSendSelfAdvert = 7;
|
|
||||||
static const int cmdSetAdvertName = 8;
|
|
||||||
static const int cmdAddUpdateContact = 9;
|
|
||||||
static const int cmdSyncNextMessage = 10;
|
|
||||||
static const int cmdSetRadioParams = 11;
|
|
||||||
static const int cmdSetTxPower = 12;
|
|
||||||
static const int cmdResetPath = 13;
|
|
||||||
static const int cmdSetAdvertLatLon = 14;
|
|
||||||
static const int cmdRemoveContact = 15;
|
|
||||||
static const int cmdShareContact = 16;
|
|
||||||
static const int cmdExportContact = 17;
|
|
||||||
static const int cmdImportContact = 18;
|
|
||||||
static const int cmdReboot = 19;
|
|
||||||
static const int cmdGetBatteryVoltage = 20;
|
|
||||||
static const int cmdSetTuningParams = 21;
|
|
||||||
static const int cmdDeviceQuery = 22;
|
|
||||||
static const int cmdExportPrivateKey = 23;
|
|
||||||
static const int cmdImportPrivateKey = 24;
|
|
||||||
static const int cmdSendRawData = 25;
|
|
||||||
static const int cmdSendLogin = 26;
|
|
||||||
static const int cmdSendStatusReq = 27;
|
|
||||||
static const int cmdHasConnection = 28;
|
|
||||||
static const int cmdLogout = 29;
|
|
||||||
static const int cmdGetContactByKey = 30;
|
|
||||||
static const int cmdGetChannel = 31;
|
|
||||||
static const int cmdSetChannel = 32;
|
|
||||||
static const int cmdSignStart = 33;
|
|
||||||
static const int cmdSignData = 34;
|
|
||||||
static const int cmdSignFinish = 35;
|
|
||||||
static const int cmdSendTracePath = 36;
|
|
||||||
static const int cmdSetDevicePin = 37;
|
|
||||||
static const int cmdSetOtherParams = 38;
|
|
||||||
static const int cmdSendTelemetryReq = 39;
|
|
||||||
static const int cmdGetCustomVars = 40;
|
|
||||||
static const int cmdSetCustomVar = 41;
|
|
||||||
static const int cmdGetAdvertPath = 42;
|
|
||||||
static const int cmdGetTuningParams = 43;
|
|
||||||
static const int cmdSendBinaryReq = 50;
|
|
||||||
static const int cmdFactoryReset = 51;
|
|
||||||
static const int cmdSendPathDiscoveryReq = 52;
|
|
||||||
static const int cmdSetFloodScope = 54; // v8+
|
|
||||||
static const int cmdSendControlData = 55; // v8+
|
|
||||||
static const int cmdGetStats = 56; // v8+
|
|
||||||
static const int cmdSendAnonReq = 57;
|
|
||||||
static const int cmdSetAutoaddConfig = 58;
|
|
||||||
static const int cmdGetAutoaddConfig = 59;
|
|
||||||
static const int cmdGetAllowedRepeatFreq = 60;
|
|
||||||
|
|
||||||
// Response Codes (Device -> App)
|
|
||||||
static const int respOk = 0;
|
|
||||||
static const int respErr = 1;
|
|
||||||
static const int respContactsStart = 2;
|
|
||||||
static const int respContact = 3;
|
|
||||||
static const int respEndOfContacts = 4;
|
|
||||||
static const int respSelfInfo = 5;
|
|
||||||
static const int respSent = 6;
|
|
||||||
static const int respContactMsgRecv = 7; // firmware ver < 3
|
|
||||||
static const int respChannelMsgRecv = 8; // firmware ver < 3
|
|
||||||
static const int respCurrTime = 9;
|
|
||||||
static const int respNoMoreMessages = 10;
|
|
||||||
static const int respExportContact = 11;
|
|
||||||
static const int respBatteryVoltage = 12;
|
|
||||||
static const int respDeviceInfo = 13;
|
|
||||||
static const int respPrivateKey = 14;
|
|
||||||
static const int respDisabled = 15;
|
|
||||||
static const int respContactMsgRecvV3 = 16; // firmware ver >= 3 (adds SNR header)
|
|
||||||
static const int respChannelMsgRecvV3 = 17; // firmware ver >= 3 (adds SNR header)
|
|
||||||
static const int respChannelInfo = 18;
|
|
||||||
static const int respSignStart = 19;
|
|
||||||
static const int respSignature = 20;
|
|
||||||
static const int respCustomVars = 21;
|
|
||||||
static const int respAdvertPath = 22;
|
|
||||||
static const int respTuningParams = 23;
|
|
||||||
static const int respStats = 24; // v8+
|
|
||||||
static const int respAutoaddConfig = 25;
|
|
||||||
static const int respAllowedRepeatFreq = 26;
|
|
||||||
|
|
||||||
// Push Codes (Device -> App, unsolicited)
|
|
||||||
static const int pushAdvert = 0x80;
|
|
||||||
static const int pushPathUpdated = 0x81;
|
|
||||||
static const int pushSendConfirmed = 0x82;
|
|
||||||
static const int pushMsgWaiting = 0x83;
|
|
||||||
static const int pushRawData = 0x84;
|
|
||||||
static const int pushLoginSuccess = 0x85;
|
|
||||||
static const int pushLoginFail = 0x86;
|
|
||||||
static const int pushStatusResponse = 0x87;
|
|
||||||
static const int pushLogRxData = 0x88;
|
|
||||||
static const int pushTraceData = 0x89;
|
|
||||||
static const int pushNewAdvert = 0x8A;
|
|
||||||
static const int pushTelemetryResponse = 0x8B;
|
|
||||||
static const int pushBinaryResponse = 0x8C;
|
|
||||||
static const int pushPathDiscoveryResponse = 0x8D;
|
|
||||||
static const int pushControlData = 0x8E; // v8+
|
|
||||||
static const int pushContactDeleted = 0x8F; // contact overwritten when contacts full
|
|
||||||
static const int pushContactsFull = 0x90; // contacts storage is full
|
|
||||||
|
|
||||||
// Stats sub-types for cmdGetStats
|
|
||||||
static const int statsTypeCore = 0;
|
|
||||||
static const int statsTypeRadio = 1;
|
|
||||||
static const int statsTypePackets = 2;
|
|
||||||
|
|
||||||
// Error Codes
|
|
||||||
static const int errUnsupportedCmd = 1;
|
|
||||||
static const int errNotFound = 2;
|
|
||||||
static const int errTableFull = 3;
|
|
||||||
static const int errBadState = 4;
|
|
||||||
static const int errFileIoError = 5;
|
|
||||||
static const int errIllegalArg = 6;
|
|
||||||
|
|
||||||
// Advert Types
|
|
||||||
static const int advTypeNone = 0;
|
|
||||||
static const int advTypeChat = 1;
|
|
||||||
static const int advTypeRepeater = 2;
|
|
||||||
static const int advTypeRoom = 3;
|
|
||||||
|
|
||||||
// Self Advert Types
|
|
||||||
static const int selfAdvertZeroHop = 0;
|
|
||||||
static const int selfAdvertFlood = 1;
|
|
||||||
|
|
||||||
// Text Types
|
|
||||||
static const int txtTypePlain = 0;
|
|
||||||
static const int txtTypeCliData = 1;
|
|
||||||
static const int txtTypeSignedPlain = 2;
|
|
||||||
|
|
||||||
// Binary Request Types
|
|
||||||
static const int binaryReqGetTelemetryData = 0x03;
|
|
||||||
static const int binaryReqGetAvgMinMax = 0x04;
|
|
||||||
static const int binaryReqGetAccessList = 0x05;
|
|
||||||
static const int binaryReqGetNeighbours = 0x06;
|
|
||||||
|
|
||||||
// Default Public Channel Secret (128-bit)
|
|
||||||
// This is the well-known pre-shared key for the public channel (channel 0)
|
|
||||||
// Hex: 8b3387e9c5cdea6ac9e5edbaa115cd72
|
|
||||||
// Base64: izOH6cXN6mrJ5e26oRXNcg==
|
|
||||||
// Source: https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md
|
|
||||||
static const List<int> defaultPublicChannelSecret = [
|
|
||||||
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
|
|
||||||
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Cayenne LPP Data Types
|
|
||||||
static const int lppDigitalInput = 0;
|
|
||||||
static const int lppDigitalOutput = 1;
|
|
||||||
static const int lppAnalogInput = 2;
|
|
||||||
static const int lppAnalogOutput = 3;
|
|
||||||
static const int lppIlluminanceSensor = 101;
|
|
||||||
static const int lppPresenceSensor = 102;
|
|
||||||
static const int lppTemperatureSensor = 103;
|
|
||||||
static const int lppHumiditySensor = 104;
|
|
||||||
static const int lppAccelerometer = 113;
|
|
||||||
static const int lppBarometer = 115;
|
|
||||||
static const int lppVoltageSensor = 116;
|
|
||||||
static const int lppGyrometer = 134;
|
|
||||||
static const int lppGps = 136;
|
|
||||||
|
|
||||||
// MTU and timing
|
|
||||||
static const int maxMtuSize = 512;
|
|
||||||
static const int defaultTimeout = 5000; // 5 seconds
|
|
||||||
static const int reconnectDelay = 2000; // 2 seconds
|
|
||||||
static const int telemetryUpdateInterval = 300000; // 5 minutes
|
|
||||||
|
|
||||||
MeshCoreConstants._(); // Private constructor to prevent instantiation
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
import '../../models/contact.dart';
|
|
||||||
import '../buffer_writer.dart';
|
|
||||||
import '../meshcore_constants.dart';
|
|
||||||
|
|
||||||
/// Builds outgoing BLE frames for the MeshCore device
|
|
||||||
class FrameBuilder {
|
|
||||||
/// Build DeviceQuery command
|
|
||||||
static Uint8List buildDeviceQuery() {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdDeviceQuery);
|
|
||||||
writer.writeByte(MeshCoreConstants.supportedCompanionProtocolVersion);
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build AppStart command
|
|
||||||
static Uint8List buildAppStart() {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdAppStart);
|
|
||||||
writer.writeByte(1); // appVer
|
|
||||||
writer.writeBytes(Uint8List(6)); // reserved
|
|
||||||
writer.writeString('MeshCore SAR'); // appName
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build GetContacts command
|
|
||||||
static Uint8List buildGetContacts() {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdGetContacts);
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build GetContactByKey command - retrieves a single contact by public key
|
|
||||||
static Uint8List buildGetContactByKey(Uint8List publicKey) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdGetContactByKey); // 0x1E (30)
|
|
||||||
writer.writeBytes(publicKey); // 32 bytes
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build AddUpdateContact command
|
|
||||||
static Uint8List buildAddUpdateContact(Contact contact) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdAddUpdateContact); // 0x09
|
|
||||||
writer.writeBytes(contact.publicKey); // 32 bytes
|
|
||||||
writer.writeByte(contact.type.value); // ADV_TYPE_*
|
|
||||||
writer.writeByte(contact.flags); // flags
|
|
||||||
writer.writeInt8(contact.outPathLen); // path length (signed byte)
|
|
||||||
writer.writeBytes(contact.outPath); // 64 bytes
|
|
||||||
|
|
||||||
// Write name as null-terminated string in 32-byte field
|
|
||||||
final nameBytes = Uint8List(32);
|
|
||||||
final encoded = utf8.encode(contact.advName);
|
|
||||||
final copyLen = encoded.length > 31 ? 31 : encoded.length;
|
|
||||||
nameBytes.setRange(0, copyLen, encoded);
|
|
||||||
writer.writeBytes(nameBytes);
|
|
||||||
|
|
||||||
writer.writeUInt32LE(contact.lastAdvert); // timestamp
|
|
||||||
writer.writeInt32LE(contact.advLat); // latitude * 1E6
|
|
||||||
writer.writeInt32LE(contact.advLon); // longitude * 1E6
|
|
||||||
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build SendTxtMsg command
|
|
||||||
static Uint8List buildSendTxtMsg({
|
|
||||||
required Uint8List contactPublicKey,
|
|
||||||
required String text,
|
|
||||||
int textType = 0,
|
|
||||||
int attempt = 0,
|
|
||||||
}) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdSendTxtMsg); // 0x02
|
|
||||||
writer.writeByte(textType); // TXT_TYPE_*
|
|
||||||
writer.writeByte(attempt); // 0-3
|
|
||||||
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
|
||||||
writer.writeBytes(contactPublicKey.sublist(0, 6));
|
|
||||||
writer.writeString(text);
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build SendChannelTxtMsg command
|
|
||||||
static Uint8List buildSendChannelTxtMsg({
|
|
||||||
required int channelIdx,
|
|
||||||
required String text,
|
|
||||||
int textType = 0,
|
|
||||||
}) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdSendChannelTxtMsg); // 0x03
|
|
||||||
writer.writeByte(textType); // TXT_TYPE_*
|
|
||||||
writer.writeByte(channelIdx); // 0 for 'public' channel
|
|
||||||
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
|
||||||
writer.writeString(text);
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build SendTelemetryReq command
|
|
||||||
/// Requests telemetry (GPS, battery) from a contact
|
|
||||||
static Uint8List buildSendTelemetryReq(Uint8List contactPublicKey, {bool zeroHop = false}) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdSendTelemetryReq);
|
|
||||||
writer.writeByte(zeroHop ? 0 : 255);
|
|
||||||
writer.writeByte(0); // reserved
|
|
||||||
writer.writeByte(0); // reserved
|
|
||||||
writer.writeBytes(contactPublicKey);
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build SendBinaryReq command
|
|
||||||
static Uint8List buildSendBinaryReq({
|
|
||||||
required Uint8List contactPublicKey,
|
|
||||||
required Uint8List requestData,
|
|
||||||
}) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdSendBinaryReq); // 0x32 (50)
|
|
||||||
writer.writeBytes(contactPublicKey); // 32 bytes
|
|
||||||
writer.writeBytes(requestData); // request code + params
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build GetBatteryVoltage command
|
|
||||||
static Uint8List buildGetBatteryAndStorage() {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdGetBatteryVoltage);
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build SyncNextMessage command
|
|
||||||
static Uint8List buildSyncNextMessage() {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdSyncNextMessage);
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build GetDeviceTime command
|
|
||||||
static Uint8List buildGetDeviceTime() {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdGetDeviceTime);
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build SetDeviceTime command
|
|
||||||
static Uint8List buildSetDeviceTime() {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdSetDeviceTime);
|
|
||||||
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build SendSelfAdvert command
|
|
||||||
static Uint8List buildSendSelfAdvert({bool floodMode = true}) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdSendSelfAdvert);
|
|
||||||
writer.writeByte(floodMode ? MeshCoreConstants.selfAdvertFlood : MeshCoreConstants.selfAdvertZeroHop);
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build SetAdvertName command
|
|
||||||
static Uint8List buildSetAdvertName(String name) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdSetAdvertName);
|
|
||||||
writer.writeString(name);
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build SetAdvertLatLon command
|
|
||||||
static Uint8List buildSetAdvertLatLon({
|
|
||||||
required double latitude,
|
|
||||||
required double longitude,
|
|
||||||
}) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdSetAdvertLatLon);
|
|
||||||
writer.writeInt32LE((latitude * 1000000).round());
|
|
||||||
writer.writeInt32LE((longitude * 1000000).round());
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build SetRadioParams command
|
|
||||||
static Uint8List buildSetRadioParams({
|
|
||||||
required int frequency,
|
|
||||||
required int bandwidth,
|
|
||||||
required int spreadingFactor,
|
|
||||||
required int codingRate,
|
|
||||||
}) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdSetRadioParams);
|
|
||||||
writer.writeUInt32LE(frequency);
|
|
||||||
writer.writeUInt16LE(bandwidth);
|
|
||||||
writer.writeByte(spreadingFactor);
|
|
||||||
writer.writeByte(codingRate);
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build SetTxPower command
|
|
||||||
static Uint8List buildSetTxPower(int powerDbm) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdSetTxPower);
|
|
||||||
writer.writeByte(powerDbm);
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build SetOtherParams command
|
|
||||||
static Uint8List buildSetOtherParams({
|
|
||||||
required int manualAddContacts,
|
|
||||||
required int telemetryModes,
|
|
||||||
required int advertLocationPolicy,
|
|
||||||
int multiAcks = 0,
|
|
||||||
}) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdSetOtherParams);
|
|
||||||
writer.writeByte(manualAddContacts);
|
|
||||||
writer.writeByte(telemetryModes);
|
|
||||||
writer.writeByte(advertLocationPolicy);
|
|
||||||
writer.writeByte(multiAcks);
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build SendLogin command
|
|
||||||
static Uint8List buildSendLogin({
|
|
||||||
required Uint8List roomPublicKey,
|
|
||||||
required String password,
|
|
||||||
}) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdSendLogin); // 0x1A
|
|
||||||
writer.writeBytes(roomPublicKey); // 32 bytes
|
|
||||||
writer.writeString(password); // Max 15 bytes, null-terminated
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build SendStatusReq command
|
|
||||||
static Uint8List buildSendStatusReq(Uint8List contactPublicKey) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdSendStatusReq); // 0x1B
|
|
||||||
writer.writeBytes(contactPublicKey); // 32 bytes
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build ResetPath command - clears learned path for a contact
|
|
||||||
static Uint8List buildResetPath(Uint8List contactPublicKey) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdResetPath); // 0x0D (13)
|
|
||||||
writer.writeBytes(contactPublicKey); // 32 bytes
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build RemoveContact command - removes a contact from the device
|
|
||||||
static Uint8List buildRemoveContact(Uint8List contactPublicKey) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdRemoveContact); // 0x0F (15)
|
|
||||||
writer.writeBytes(contactPublicKey); // 32 bytes
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build GetChannel command - retrieves information for a specific channel
|
|
||||||
static Uint8List buildGetChannel(int channelIdx) {
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdGetChannel); // 0x1F (31)
|
|
||||||
writer.writeByte(channelIdx); // 0-39 typically
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build SetChannel command - sets the name and secret for a specific channel
|
|
||||||
///
|
|
||||||
/// Format: [cmd(1)][channel_idx(1)][name(32)][secret(16)]
|
|
||||||
/// Secret must be exactly 16 bytes (128-bit key)
|
|
||||||
static Uint8List buildSetChannel({
|
|
||||||
required int channelIdx,
|
|
||||||
required String channelName,
|
|
||||||
required List<int> secret,
|
|
||||||
}) {
|
|
||||||
if (secret.length != 16) {
|
|
||||||
throw ArgumentError('Channel secret must be exactly 16 bytes (got ${secret.length})');
|
|
||||||
}
|
|
||||||
|
|
||||||
final writer = BufferWriter();
|
|
||||||
writer.writeByte(MeshCoreConstants.cmdSetChannel); // 0x20 (32)
|
|
||||||
writer.writeByte(channelIdx); // 0-39 typically
|
|
||||||
|
|
||||||
// Write channel name as null-terminated string in 32-byte field
|
|
||||||
final nameBytes = Uint8List(32);
|
|
||||||
final encoded = utf8.encode(channelName);
|
|
||||||
final copyLen = encoded.length > 31 ? 31 : encoded.length;
|
|
||||||
nameBytes.setRange(0, copyLen, encoded);
|
|
||||||
writer.writeBytes(nameBytes);
|
|
||||||
|
|
||||||
// Write 16-byte secret
|
|
||||||
writer.writeBytes(Uint8List.fromList(secret));
|
|
||||||
|
|
||||||
return writer.toBytes();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
import '../../models/contact.dart';
|
|
||||||
import '../../models/message.dart';
|
|
||||||
import '../buffer_reader.dart';
|
|
||||||
import '../meshcore_constants.dart';
|
|
||||||
|
|
||||||
/// Parses incoming BLE frames from the MeshCore device
|
|
||||||
class FrameParser {
|
|
||||||
/// Parse ContactsStart response
|
|
||||||
static int parseContactsStart(BufferReader reader) {
|
|
||||||
return reader.readUInt32LE();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse Contact response
|
|
||||||
static Contact parseContact(BufferReader reader) {
|
|
||||||
final publicKey = reader.readBytes(32);
|
|
||||||
final typeByte = reader.readByte();
|
|
||||||
final type = ContactType.fromValue(typeByte);
|
|
||||||
final flags = reader.readByte();
|
|
||||||
final outPathLen = reader.readInt8();
|
|
||||||
final outPath = reader.readBytes(64);
|
|
||||||
final advName = reader.readCString(32);
|
|
||||||
final lastAdvert = reader.readUInt32LE();
|
|
||||||
final advLat = reader.readInt32LE();
|
|
||||||
final advLon = reader.readInt32LE();
|
|
||||||
final lastMod = reader.readUInt32LE();
|
|
||||||
|
|
||||||
return Contact(
|
|
||||||
publicKey: publicKey,
|
|
||||||
type: type,
|
|
||||||
flags: flags,
|
|
||||||
outPathLen: outPathLen,
|
|
||||||
outPath: outPath,
|
|
||||||
advName: advName,
|
|
||||||
lastAdvert: lastAdvert,
|
|
||||||
advLat: advLat,
|
|
||||||
advLon: advLon,
|
|
||||||
lastMod: lastMod,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse Sent confirmation response
|
|
||||||
static Map<String, dynamic> parseSentConfirmation(BufferReader reader) {
|
|
||||||
if (reader.remainingBytesCount >= 9) {
|
|
||||||
final sendType = reader.readByte();
|
|
||||||
final isFloodMode = sendType == 1;
|
|
||||||
final expectedAckOrTagBytes = reader.readBytes(4);
|
|
||||||
final expectedAckTag = ByteData.sublistView(Uint8List.fromList(expectedAckOrTagBytes))
|
|
||||||
.getUint32(0, Endian.little);
|
|
||||||
final suggestedTimeout = reader.readUInt32LE();
|
|
||||||
|
|
||||||
return {
|
|
||||||
'expectedAckTag': expectedAckTag,
|
|
||||||
'suggestedTimeout': suggestedTimeout,
|
|
||||||
'isFloodMode': isFloodMode,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse ContactMessage V3 response (firmware ver >= 3)
|
|
||||||
/// V3 prepends 3 bytes: [snr_scaled(int8)][reserved][reserved]
|
|
||||||
/// snr_dB = snr_scaled / 4.0
|
|
||||||
static Message parseContactMessageV3(BufferReader reader) {
|
|
||||||
reader.readInt8(); // snr scaled by 4 (ignored for now)
|
|
||||||
reader.readBytes(2); // reserved
|
|
||||||
return parseContactMessage(reader);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse ChannelMessage V3 response (firmware ver >= 3)
|
|
||||||
/// V3 prepends 3 bytes: [snr_scaled(int8)][reserved][reserved]
|
|
||||||
static Message parseChannelMessageV3(BufferReader reader) {
|
|
||||||
reader.readInt8(); // snr scaled by 4 (ignored for now)
|
|
||||||
reader.readBytes(2); // reserved
|
|
||||||
return parseChannelMessage(reader);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse ContactMessage response
|
|
||||||
static Message parseContactMessage(BufferReader reader) {
|
|
||||||
final pubKeyPrefix = reader.readBytes(6);
|
|
||||||
final pathLen = reader.readByte();
|
|
||||||
final txtTypeByte = reader.readByte();
|
|
||||||
final txtType = MessageTextType.fromValue(txtTypeByte);
|
|
||||||
final senderTimestamp = reader.readUInt32LE();
|
|
||||||
|
|
||||||
String text;
|
|
||||||
if (txtType == MessageTextType.signedPlain) {
|
|
||||||
// Signed message format: [4-byte sender prefix][UTF-8 text]
|
|
||||||
if (reader.remainingBytesCount >= 4) {
|
|
||||||
reader.readBytes(4); // Skip extra sender prefix
|
|
||||||
text = reader.hasRemaining ? reader.readString() : '';
|
|
||||||
} else {
|
|
||||||
text = reader.readString();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
text = reader.readString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Message(
|
|
||||||
id: '${DateTime.now().millisecondsSinceEpoch}_${pubKeyPrefix.map((b) => b.toRadixString(16)).join()}',
|
|
||||||
messageType: MessageType.contact,
|
|
||||||
senderPublicKeyPrefix: pubKeyPrefix,
|
|
||||||
pathLen: pathLen,
|
|
||||||
textType: txtType,
|
|
||||||
senderTimestamp: senderTimestamp,
|
|
||||||
text: text,
|
|
||||||
receivedAt: DateTime.now(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse ChannelMessage response
|
|
||||||
static Message parseChannelMessage(BufferReader reader) {
|
|
||||||
final channelIdx = reader.readByte(); // unsigned 0-255, not signed
|
|
||||||
final pathLen = reader.readByte();
|
|
||||||
final txtTypeByte = reader.readByte();
|
|
||||||
final txtType = MessageTextType.fromValue(txtTypeByte);
|
|
||||||
final senderTimestamp = reader.readUInt32LE();
|
|
||||||
|
|
||||||
String text;
|
|
||||||
if (txtType == MessageTextType.signedPlain) {
|
|
||||||
if (reader.remainingBytesCount >= 4) {
|
|
||||||
reader.readBytes(4); // Skip extra sender prefix
|
|
||||||
text = reader.hasRemaining ? reader.readString() : '';
|
|
||||||
} else {
|
|
||||||
text = reader.readString();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
text = reader.readString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse sender name from channel message format: "<sender_name>: <actual_message>"
|
|
||||||
String? senderName;
|
|
||||||
String actualMessage = text;
|
|
||||||
|
|
||||||
if (text.contains(': ')) {
|
|
||||||
final colonIndex = text.indexOf(': ');
|
|
||||||
senderName = text.substring(0, colonIndex);
|
|
||||||
actualMessage = text.substring(colonIndex + 2); // Skip ": "
|
|
||||||
}
|
|
||||||
|
|
||||||
return Message(
|
|
||||||
id: '${DateTime.now().millisecondsSinceEpoch}_ch$channelIdx',
|
|
||||||
messageType: MessageType.channel,
|
|
||||||
channelIdx: channelIdx,
|
|
||||||
pathLen: pathLen,
|
|
||||||
textType: txtType,
|
|
||||||
senderTimestamp: senderTimestamp,
|
|
||||||
text: actualMessage, // Store the actual message without sender prefix
|
|
||||||
senderName: senderName, // Store extracted sender name
|
|
||||||
receivedAt: DateTime.now(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse TelemetryResponse push
|
|
||||||
static Map<String, dynamic> parseTelemetryResponse(BufferReader reader) {
|
|
||||||
reader.readByte(); // reserved
|
|
||||||
final pubKeyPrefix = reader.readBytes(6);
|
|
||||||
final lppSensorData = reader.readRemainingBytes();
|
|
||||||
|
|
||||||
return {
|
|
||||||
'publicKeyPrefix': pubKeyPrefix,
|
|
||||||
'lppSensorData': lppSensorData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse BinaryResponse push
|
|
||||||
static Map<String, dynamic> parseBinaryResponse(BufferReader reader) {
|
|
||||||
reader.readByte(); // reserved
|
|
||||||
final tag = reader.readUInt32LE();
|
|
||||||
final responseData = reader.readRemainingBytes();
|
|
||||||
|
|
||||||
return {
|
|
||||||
'publicKeyPrefix': Uint8List(6), // Empty prefix
|
|
||||||
'tag': tag,
|
|
||||||
'responseData': responseData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse DeviceInfo response
|
|
||||||
static Map<String, dynamic> parseDeviceInfo(BufferReader reader) {
|
|
||||||
if (reader.remainingBytesCount < 1) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
final firmwareVersion = reader.readByte();
|
|
||||||
|
|
||||||
int? maxContacts;
|
|
||||||
int? maxChannels;
|
|
||||||
int? blePin;
|
|
||||||
if (reader.remainingBytesCount >= 6) {
|
|
||||||
final maxContactsDiv2 = reader.readByte();
|
|
||||||
maxContacts = maxContactsDiv2 * 2;
|
|
||||||
maxChannels = reader.readByte();
|
|
||||||
blePin = reader.readUInt32LE();
|
|
||||||
}
|
|
||||||
|
|
||||||
String? firmwareBuildDate;
|
|
||||||
if (reader.remainingBytesCount >= 12) {
|
|
||||||
final buildDateBytes = reader.readBytes(12);
|
|
||||||
firmwareBuildDate =
|
|
||||||
String.fromCharCodes(buildDateBytes.takeWhile((b) => b != 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
String? manufacturerModel;
|
|
||||||
if (reader.remainingBytesCount >= 40) {
|
|
||||||
final modelBytes = reader.readBytes(40);
|
|
||||||
manufacturerModel =
|
|
||||||
String.fromCharCodes(modelBytes.takeWhile((b) => b != 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
String? semanticVersion;
|
|
||||||
if (reader.remainingBytesCount >= 20) {
|
|
||||||
final versionBytes = reader.readBytes(20);
|
|
||||||
semanticVersion =
|
|
||||||
String.fromCharCodes(versionBytes.takeWhile((b) => b != 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
'firmwareVersion': firmwareVersion,
|
|
||||||
'maxContacts': maxContacts,
|
|
||||||
'maxChannels': maxChannels,
|
|
||||||
'blePin': blePin,
|
|
||||||
'firmwareBuildDate': firmwareBuildDate,
|
|
||||||
'manufacturerModel': manufacturerModel,
|
|
||||||
'semanticVersion': semanticVersion,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse SelfInfo response
|
|
||||||
static Map<String, dynamic> parseSelfInfo(BufferReader reader) {
|
|
||||||
if (reader.remainingBytesCount < 54) {
|
|
||||||
reader.readRemainingBytes();
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
final deviceType = reader.readByte();
|
|
||||||
final txPower = reader.readByte();
|
|
||||||
final maxTxPower = reader.readByte();
|
|
||||||
final publicKey = reader.readBytes(32);
|
|
||||||
|
|
||||||
final advLatBytes = reader.readBytes(4);
|
|
||||||
final advLat = ByteData.sublistView(Uint8List.fromList(advLatBytes))
|
|
||||||
.getInt32(0, Endian.little);
|
|
||||||
|
|
||||||
final advLonBytes = reader.readBytes(4);
|
|
||||||
final advLon = ByteData.sublistView(Uint8List.fromList(advLonBytes))
|
|
||||||
.getInt32(0, Endian.little);
|
|
||||||
|
|
||||||
reader.readByte(); // multiAcks (reserved for future use)
|
|
||||||
reader.readByte(); // advertLocPolicy (reserved for future use)
|
|
||||||
reader.readByte(); // telemetryModes (reserved for future use)
|
|
||||||
final manualAddContacts = reader.readByte();
|
|
||||||
|
|
||||||
final radioFreqBytes = reader.readBytes(4);
|
|
||||||
final radioFreq = ByteData.sublistView(Uint8List.fromList(radioFreqBytes))
|
|
||||||
.getUint32(0, Endian.little);
|
|
||||||
|
|
||||||
final radioBwBytes = reader.readBytes(4);
|
|
||||||
final radioBw = ByteData.sublistView(Uint8List.fromList(radioBwBytes))
|
|
||||||
.getUint32(0, Endian.little);
|
|
||||||
|
|
||||||
final radioSf = reader.readByte();
|
|
||||||
final radioCr = reader.readByte();
|
|
||||||
|
|
||||||
String? selfName;
|
|
||||||
if (reader.hasRemaining) {
|
|
||||||
final nameBytes = reader.readRemainingBytes();
|
|
||||||
selfName = utf8.decode(nameBytes.takeWhile((b) => b != 0).toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
'deviceType': deviceType,
|
|
||||||
'txPower': txPower,
|
|
||||||
'maxTxPower': maxTxPower,
|
|
||||||
'publicKey': publicKey,
|
|
||||||
'advLat': advLat,
|
|
||||||
'advLon': advLon,
|
|
||||||
'manualAddContacts': manualAddContacts == 1,
|
|
||||||
'radioFreq': radioFreq,
|
|
||||||
'radioBw': radioBw,
|
|
||||||
'radioSf': radioSf,
|
|
||||||
'radioCr': radioCr,
|
|
||||||
'selfName': selfName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse Advert push
|
|
||||||
static Uint8List? parseAdvert(BufferReader reader) {
|
|
||||||
if (reader.remainingBytesCount >= 32) {
|
|
||||||
return reader.readBytes(32);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse PathUpdated push
|
|
||||||
static Uint8List? parsePathUpdated(BufferReader reader) {
|
|
||||||
if (reader.remainingBytesCount >= 32) {
|
|
||||||
return reader.readBytes(32);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse SendConfirmed push
|
|
||||||
static Map<String, dynamic> parseSendConfirmed(BufferReader reader) {
|
|
||||||
if (reader.remainingBytesCount >= 8) {
|
|
||||||
final ackCodeBytes = reader.readBytes(4);
|
|
||||||
final ackCode = ByteData.sublistView(Uint8List.fromList(ackCodeBytes))
|
|
||||||
.getUint32(0, Endian.little);
|
|
||||||
final roundTripTime = reader.readUInt32LE();
|
|
||||||
|
|
||||||
return {
|
|
||||||
'ackCode': ackCode,
|
|
||||||
'roundTripTime': roundTripTime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse LoginSuccess push
|
|
||||||
static Map<String, dynamic> parseLoginSuccess(BufferReader reader) {
|
|
||||||
if (reader.remainingBytesCount >= 11) {
|
|
||||||
final permissions = reader.readByte();
|
|
||||||
final isAdmin = (permissions & 0x01) != 0;
|
|
||||||
final publicKeyPrefix = reader.readBytes(6);
|
|
||||||
final tag = reader.readInt32LE();
|
|
||||||
|
|
||||||
int? newPermissions;
|
|
||||||
if (reader.hasRemaining) {
|
|
||||||
newPermissions = reader.readByte();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
'publicKeyPrefix': publicKeyPrefix,
|
|
||||||
'permissions': permissions,
|
|
||||||
'isAdmin': isAdmin,
|
|
||||||
'tag': tag,
|
|
||||||
'newPermissions': newPermissions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse LoginFail push
|
|
||||||
static Uint8List? parseLoginFail(BufferReader reader) {
|
|
||||||
if (reader.remainingBytesCount >= 7) {
|
|
||||||
reader.readByte(); // reserved
|
|
||||||
return reader.readBytes(6);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse StatusResponse push
|
|
||||||
static Map<String, dynamic> parseStatusResponse(BufferReader reader) {
|
|
||||||
if (reader.remainingBytesCount >= 7) {
|
|
||||||
reader.readByte(); // reserved
|
|
||||||
final publicKeyPrefix = reader.readBytes(6);
|
|
||||||
final statusData = reader.readRemainingBytes();
|
|
||||||
|
|
||||||
return {
|
|
||||||
'publicKeyPrefix': publicKeyPrefix,
|
|
||||||
'statusData': statusData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse CurrentTime response
|
|
||||||
static int? parseCurrentTime(BufferReader reader) {
|
|
||||||
if (reader.remainingBytesCount >= 4) {
|
|
||||||
return reader.readUInt32LE();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse BatteryAndStorage response
|
|
||||||
static Map<String, dynamic> parseBatteryAndStorage(BufferReader reader) {
|
|
||||||
if (reader.remainingBytesCount >= 2) {
|
|
||||||
final millivolts = reader.readUInt16LE();
|
|
||||||
|
|
||||||
int? usedKb;
|
|
||||||
int? totalKb;
|
|
||||||
|
|
||||||
if (reader.remainingBytesCount >= 8) {
|
|
||||||
usedKb = reader.readUInt32LE();
|
|
||||||
totalKb = reader.readUInt32LE();
|
|
||||||
} else if (reader.remainingBytesCount >= 4) {
|
|
||||||
usedKb = reader.readUInt32LE();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
'millivolts': millivolts,
|
|
||||||
'usedKb': usedKb,
|
|
||||||
'totalKb': totalKb,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse Error response
|
|
||||||
static int? parseError(BufferReader reader) {
|
|
||||||
if (reader.hasRemaining) {
|
|
||||||
return reader.readByte();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse ChannelInfo response
|
|
||||||
static Map<String, dynamic> parseChannelInfo(BufferReader reader) {
|
|
||||||
// Format: [channel_idx(1)][name(32)][secret(16)][flags(1)?]
|
|
||||||
// Minimum: 1 + 32 + 16 = 49 bytes (flags is optional)
|
|
||||||
if (reader.remainingBytesCount < 49) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
final channelIdx = reader.readByte();
|
|
||||||
final channelName = reader.readCString(32);
|
|
||||||
final secret = reader.readBytes(16);
|
|
||||||
|
|
||||||
// Flags field is optional (some firmware versions don't include it)
|
|
||||||
int? flags;
|
|
||||||
if (reader.remainingBytesCount >= 1) {
|
|
||||||
flags = reader.readByte();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
'channelIdx': channelIdx,
|
|
||||||
'channelName': channelName,
|
|
||||||
'secret': secret,
|
|
||||||
'flags': flags,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get error message from error code
|
|
||||||
static String getErrorMessage(int errorCode) {
|
|
||||||
switch (errorCode) {
|
|
||||||
case MeshCoreConstants.errUnsupportedCmd:
|
|
||||||
return 'Unsupported command';
|
|
||||||
case MeshCoreConstants.errNotFound:
|
|
||||||
return 'Not found';
|
|
||||||
case MeshCoreConstants.errTableFull:
|
|
||||||
return 'Table full';
|
|
||||||
case MeshCoreConstants.errBadState:
|
|
||||||
return 'Bad state';
|
|
||||||
case MeshCoreConstants.errFileIoError:
|
|
||||||
return 'File I/O error';
|
|
||||||
case MeshCoreConstants.errIllegalArg:
|
|
||||||
return 'Illegal argument';
|
|
||||||
default:
|
|
||||||
return 'Error code: $errorCode';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import 'dart:math';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/contact_telemetry.dart';
|
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -589,6 +589,13 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.4.2"
|
version: "0.4.2"
|
||||||
|
meshcore_client:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "../meshcore_client"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.1.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ dependencies:
|
|||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
|
|
||||||
|
# MeshCore BLE protocol client
|
||||||
|
meshcore_client:
|
||||||
|
path: ../meshcore_client
|
||||||
|
|
||||||
# BLE connectivity
|
# BLE connectivity
|
||||||
flutter_blue_plus: ^2.0.0
|
flutter_blue_plus: ^2.0.0
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:latlong2/latlong.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/cayenne_lpp_parser.dart';
|
||||||
import 'package:meshcore_sar_app/services/meshcore_constants.dart';
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('CayenneLppParser - GPS Codec Tests', () {
|
group('CayenneLppParser - GPS Codec Tests', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user