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';
|
||||
|
||||
/// Single advertisement location point in a contact's movement history
|
||||
class AdvertLocation {
|
||||
final LatLng location;
|
||||
final DateTime timestamp;
|
||||
|
||||
AdvertLocation({
|
||||
required this.location,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
/// Get friendly time ago display
|
||||
String get timeAgo {
|
||||
final diff = DateTime.now().difference(timestamp);
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AdvertLocation(lat: ${location.latitude.toStringAsFixed(6)}, '
|
||||
'lon: ${location.longitude.toStringAsFixed(6)}, '
|
||||
'time: ${timestamp.toIso8601String()})';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is AdvertLocation &&
|
||||
other.location.latitude == location.latitude &&
|
||||
other.location.longitude == location.longitude &&
|
||||
other.timestamp == timestamp;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(location.latitude, location.longitude, timestamp);
|
||||
}
|
||||
export 'package:meshcore_client/meshcore_client.dart' show AdvertLocation;
|
||||
|
||||
@@ -1,121 +1,2 @@
|
||||
import 'dart:typed_data';
|
||||
import '../services/meshcore_opcode_names.dart';
|
||||
|
||||
/// Decoded LOG_RX_DATA packet structure
|
||||
class LogRxDataInfo {
|
||||
final int? airtimeMs;
|
||||
final Uint8List? senderPublicKey;
|
||||
final int? ackCode;
|
||||
final List<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
|
||||
}
|
||||
export 'package:meshcore_client/meshcore_client.dart'
|
||||
show BlePacketLog, PacketDirection, LogRxDataInfo;
|
||||
|
||||
@@ -1,364 +1,17 @@
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
export 'package:meshcore_client/meshcore_client.dart'
|
||||
show Contact, ContactType, ContactTelemetry, AdvertLocation;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'contact_telemetry.dart';
|
||||
import 'advert_location.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
|
||||
/// MeshCore contact types
|
||||
enum ContactType {
|
||||
none(0),
|
||||
chat(1),
|
||||
repeater(2),
|
||||
room(3),
|
||||
channel(99); // Virtual type for public channel (not from protocol)
|
||||
|
||||
const ContactType(this.value);
|
||||
final int value;
|
||||
|
||||
static ContactType fromValue(int value) {
|
||||
return ContactType.values.firstWhere(
|
||||
(e) => e.value == value,
|
||||
orElse: () => ContactType.none,
|
||||
);
|
||||
}
|
||||
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case ContactType.chat:
|
||||
return 'Chat';
|
||||
case ContactType.repeater:
|
||||
return 'Repeater';
|
||||
case ContactType.room:
|
||||
return 'Room';
|
||||
case ContactType.channel:
|
||||
return 'Channel';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MeshCore contact model
|
||||
class Contact {
|
||||
final Uint8List publicKey;
|
||||
final ContactType type;
|
||||
final int flags;
|
||||
final int outPathLen;
|
||||
final Uint8List outPath;
|
||||
final String advName;
|
||||
final int lastAdvert; // Unix timestamp
|
||||
final int advLat; // Latitude as int32
|
||||
final int advLon; // Longitude as int32
|
||||
final int lastMod; // Unix timestamp
|
||||
|
||||
// Telemetry data (updated separately)
|
||||
ContactTelemetry? telemetry;
|
||||
|
||||
// Advertisement location history (most recent first)
|
||||
final List<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)
|
||||
extension ContactLocalization on Contact {
|
||||
/// Returns the localized display name for special contacts (e.g. Public Channel).
|
||||
/// For all other contacts, returns [displayName].
|
||||
String getLocalizedDisplayName(BuildContext context) {
|
||||
// Check if this is the Public Channel (all-zeros public key)
|
||||
if (isPublicChannel) {
|
||||
return AppLocalizations.of(context)!.publicChannel;
|
||||
}
|
||||
// For all other contacts, use the regular display name
|
||||
return displayName;
|
||||
}
|
||||
|
||||
/// Check if contact has a learned routing path
|
||||
/// When true, messages will use direct routing. When false, messages will use flood mode.
|
||||
/// outPathLen: -1 = unknown/not learned, 0 = direct (zero hops), 1+ = multi-hop path
|
||||
bool get hasPath => outPathLen >= 0 && outPathLen <= 64;
|
||||
|
||||
/// Get path description for UI display
|
||||
String get pathDescription {
|
||||
if (!hasPath) {
|
||||
// -1 (0xFF) indicates path not learned yet
|
||||
return 'No path (flood mode)';
|
||||
}
|
||||
|
||||
// outPathLen = 0 means direct connection with zero hops
|
||||
// outPathLen >= 1 means path with N hops
|
||||
if (outPathLen == 0) {
|
||||
return 'Direct (0 hops)';
|
||||
} else if (outPathLen == 1) {
|
||||
return 'Direct (1 hop)';
|
||||
} else if (outPathLen <= 3) {
|
||||
return 'Good path ($outPathLen hops)';
|
||||
} else if (outPathLen <= 5) {
|
||||
return 'Medium path ($outPathLen hops)';
|
||||
} else {
|
||||
return 'Long path ($outPathLen hops)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get path quality indicator (0-5 scale, higher is better)
|
||||
/// -1 means no path (will use flood mode)
|
||||
int get pathQuality {
|
||||
if (!hasPath) return -1;
|
||||
if (outPathLen == 0) return 5; // Direct connection (0 hops)
|
||||
if (outPathLen == 1) return 4; // 1 hop
|
||||
if (outPathLen <= 2) return 3; // 2 hops
|
||||
if (outPathLen <= 3) return 2; // 3 hops
|
||||
if (outPathLen <= 4) return 1; // 4 hops
|
||||
return 0; // 5+ hops
|
||||
}
|
||||
|
||||
/// Add a new advertisement location to history (maintains max 1000 points)
|
||||
///
|
||||
/// Implements location dithering to avoid storing redundant points:
|
||||
/// - Only stores points that are ≥1 meter apart (max meter accuracy)
|
||||
/// - Prevents trail clutter when contact is stationary or moving slowly
|
||||
/// - Maintains chronological order (most recent first)
|
||||
Contact addAdvertLocation(LatLng location, DateTime timestamp) {
|
||||
final newPoint = AdvertLocation(location: location, timestamp: timestamp);
|
||||
|
||||
// Dithering: Skip points within 1 meter of the last recorded position
|
||||
// This provides max meter accuracy while avoiding redundant data
|
||||
if (advertHistory.isNotEmpty) {
|
||||
final lastPoint = advertHistory.first;
|
||||
final distance = _calculateDistance(lastPoint.location, location);
|
||||
|
||||
// If less than 1 meter apart, skip this point (location dithering)
|
||||
if (distance < 1.0) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
// Add new point at the beginning (most recent first)
|
||||
final updatedHistory = [newPoint, ...advertHistory];
|
||||
|
||||
// Keep only the most recent 1000 points to limit memory usage
|
||||
final trimmedHistory = updatedHistory.length > 1000
|
||||
? updatedHistory.sublist(0, 1000)
|
||||
: updatedHistory;
|
||||
|
||||
return copyWith(advertHistory: trimmedHistory);
|
||||
}
|
||||
|
||||
/// Calculate distance between two points in meters (Haversine formula)
|
||||
double _calculateDistance(LatLng point1, LatLng point2) {
|
||||
const double earthRadius = 6371000; // meters
|
||||
final lat1 = point1.latitude * (pi / 180);
|
||||
final lat2 = point2.latitude * (pi / 180);
|
||||
final dLat = (point2.latitude - point1.latitude) * (pi / 180);
|
||||
final dLon = (point2.longitude - point1.longitude) * (pi / 180);
|
||||
|
||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
||||
cos(lat1) * cos(lat2) *
|
||||
sin(dLon / 2) * sin(dLon / 2);
|
||||
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
||||
|
||||
return earthRadius * c;
|
||||
}
|
||||
|
||||
Contact copyWith({
|
||||
Uint8List? publicKey,
|
||||
ContactType? type,
|
||||
int? flags,
|
||||
int? outPathLen,
|
||||
Uint8List? outPath,
|
||||
String? advName,
|
||||
int? lastAdvert,
|
||||
int? advLat,
|
||||
int? advLon,
|
||||
int? lastMod,
|
||||
ContactTelemetry? telemetry,
|
||||
List<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';
|
||||
|
||||
/// 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)';
|
||||
}
|
||||
}
|
||||
export 'package:meshcore_client/meshcore_client.dart' show ContactTelemetry;
|
||||
|
||||
@@ -1,264 +1,40 @@
|
||||
export 'package:meshcore_client/meshcore_client.dart'
|
||||
show
|
||||
Message,
|
||||
MessageType,
|
||||
MessageTextType,
|
||||
MessageDeliveryStatus,
|
||||
MessageRecipient;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
import 'sar_marker.dart';
|
||||
|
||||
/// Message recipient tracking for grouped messages
|
||||
class MessageRecipient {
|
||||
final Uint8List publicKey; // Full public key
|
||||
final String displayName; // Contact display name
|
||||
final MessageDeliveryStatus deliveryStatus;
|
||||
final int? expectedAckTag;
|
||||
final int? roundTripTimeMs;
|
||||
final DateTime? deliveredAt;
|
||||
final DateTime sentAt;
|
||||
|
||||
const MessageRecipient({
|
||||
required this.publicKey,
|
||||
required this.displayName,
|
||||
required this.deliveryStatus,
|
||||
this.expectedAckTag,
|
||||
this.roundTripTimeMs,
|
||||
this.deliveredAt,
|
||||
required this.sentAt,
|
||||
});
|
||||
|
||||
MessageRecipient copyWith({
|
||||
Uint8List? publicKey,
|
||||
String? displayName,
|
||||
MessageDeliveryStatus? deliveryStatus,
|
||||
int? expectedAckTag,
|
||||
int? roundTripTimeMs,
|
||||
DateTime? deliveredAt,
|
||||
DateTime? sentAt,
|
||||
}) {
|
||||
return MessageRecipient(
|
||||
publicKey: publicKey ?? this.publicKey,
|
||||
displayName: displayName ?? this.displayName,
|
||||
deliveryStatus: deliveryStatus ?? this.deliveryStatus,
|
||||
expectedAckTag: expectedAckTag ?? this.expectedAckTag,
|
||||
roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs,
|
||||
deliveredAt: deliveredAt ?? this.deliveredAt,
|
||||
sentAt: sentAt ?? this.sentAt,
|
||||
);
|
||||
}
|
||||
|
||||
String get publicKeyShort {
|
||||
return publicKey
|
||||
.sublist(0, publicKey.length < 6 ? publicKey.length : 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
||||
/// Message text types from MeshCore protocol
|
||||
enum MessageTextType {
|
||||
plain(0),
|
||||
cliData(1),
|
||||
signedPlain(2);
|
||||
|
||||
const MessageTextType(this.value);
|
||||
final int value;
|
||||
|
||||
static MessageTextType fromValue(int value) {
|
||||
return MessageTextType.values.firstWhere(
|
||||
(e) => e.value == value,
|
||||
orElse: () => MessageTextType.plain,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Message type (contact, channel, or system)
|
||||
enum MessageType {
|
||||
contact,
|
||||
channel,
|
||||
system, // System messages (log entries, status updates)
|
||||
}
|
||||
|
||||
/// Message delivery status
|
||||
enum MessageDeliveryStatus {
|
||||
sending, // Message is being sent
|
||||
sent, // Message queued with expected ACK
|
||||
delivered, // Delivery confirmed (ACK received)
|
||||
failed, // Delivery failed
|
||||
received, // Message received from another contact
|
||||
}
|
||||
|
||||
/// MeshCore message model
|
||||
class Message {
|
||||
final String id;
|
||||
final MessageType messageType;
|
||||
final Uint8List? senderPublicKeyPrefix; // 6 bytes for contact messages
|
||||
final int? channelIdx; // For channel messages
|
||||
final int pathLen;
|
||||
final MessageTextType textType;
|
||||
final int senderTimestamp; // Unix timestamp
|
||||
final String text;
|
||||
|
||||
// SAR marker data (if this is a SAR message)
|
||||
final bool isSarMarker;
|
||||
final LatLng? sarGpsCoordinates;
|
||||
final String? sarNotes; // Optional message/notes for SAR marker
|
||||
final String? sarCustomEmoji; // Custom emoji for unknown SAR marker types
|
||||
final int? sarColorIndex; // Color index (0-7) from standard palette
|
||||
|
||||
// Display metadata
|
||||
final DateTime receivedAt;
|
||||
final String? senderName;
|
||||
|
||||
// Delivery tracking (for sent messages)
|
||||
final MessageDeliveryStatus deliveryStatus;
|
||||
final int? expectedAckTag; // Expected ACK/TAG from SENT response
|
||||
final int? suggestedTimeoutMs; // Suggested timeout from SENT response
|
||||
final int? roundTripTimeMs; // RTT from SEND_CONFIRMED
|
||||
final DateTime? deliveredAt; // When delivery was confirmed
|
||||
final Uint8List?
|
||||
recipientPublicKey; // Full 32-byte public key of recipient (for retry)
|
||||
|
||||
// Retry tracking (for automatic retry with progressive timeouts)
|
||||
final int retryAttempt; // Current retry attempt (0-3), 0 = first send
|
||||
final DateTime? lastRetryAt; // When last retry was sent
|
||||
final bool
|
||||
usedFloodFallback; // Whether message fell back to flood mode after retries
|
||||
|
||||
// Read status tracking
|
||||
final bool isRead; // Whether message has been read by user
|
||||
|
||||
// Echo detection for public channel messages
|
||||
final int echoCount; // Number of times message was detected being rebroadcast
|
||||
final DateTime? firstEchoAt; // When first echo was detected
|
||||
|
||||
// Drawing message tracking
|
||||
final bool isDrawing; // Whether this message contains a map drawing
|
||||
final String? drawingId; // ID of the associated drawing (for navigation)
|
||||
|
||||
// Message grouping for bulk sends (same message to multiple recipients)
|
||||
final String? groupId; // Shared ID for messages in the same bulk send
|
||||
final List<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
|
||||
extension MessageSarExtension on Message {
|
||||
/// Infer the [SarMarkerType] from stored SAR fields.
|
||||
/// Returns null if this is not a SAR marker message.
|
||||
SarMarkerType? get sarMarkerType {
|
||||
if (!isSarMarker) return null;
|
||||
|
||||
// If we have a custom emoji stored, infer type from it
|
||||
if (sarCustomEmoji != null && sarCustomEmoji!.isNotEmpty) {
|
||||
return SarMarkerType.fromEmoji(sarCustomEmoji!);
|
||||
}
|
||||
|
||||
// Otherwise, parse the message text to extract the emoji
|
||||
final trimmed = text.trim();
|
||||
if (!trimmed.startsWith('S:')) return null;
|
||||
|
||||
// Extract emoji from format: S:<emoji>:... or S:<emoji>:<colorIndex>:...
|
||||
final parts = trimmed.split(':');
|
||||
if (parts.length < 3) return null;
|
||||
|
||||
final emoji = parts[1];
|
||||
return SarMarkerType.fromEmoji(emoji);
|
||||
return SarMarkerType.fromEmoji(parts[1]);
|
||||
}
|
||||
|
||||
/// Get sender public key as hex string
|
||||
String? get senderKeyShort {
|
||||
if (senderPublicKeyPrefix == null) return null;
|
||||
return senderPublicKeyPrefix!
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/// Get sender timestamp as DateTime
|
||||
DateTime get sentAt {
|
||||
return DateTime.fromMillisecondsSinceEpoch(senderTimestamp * 1000);
|
||||
}
|
||||
|
||||
/// Check if message is from a channel
|
||||
bool get isChannelMessage => messageType == MessageType.channel;
|
||||
|
||||
/// Check if message is from a contact
|
||||
bool get isContactMessage => messageType == MessageType.contact;
|
||||
|
||||
/// Check if message is a system message
|
||||
bool get isSystemMessage => messageType == MessageType.system;
|
||||
|
||||
/// Get friendly time since message was sent
|
||||
String get timeAgo {
|
||||
final diff = DateTime.now().difference(sentAt);
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
|
||||
/// Get display name for sender (basic fallback without contact info)
|
||||
String get displaySender {
|
||||
if (senderName != null && senderName!.isNotEmpty) {
|
||||
return senderName!;
|
||||
}
|
||||
if (senderKeyShort != null) {
|
||||
return senderKeyShort!.substring(0, 8);
|
||||
}
|
||||
if (isChannelMessage && channelIdx != null) {
|
||||
return 'Channel $channelIdx';
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/// Get rich display name for sender using contact information
|
||||
/// Returns emoji + display name if available, otherwise falls back to displaySender
|
||||
String getRichDisplayName(dynamic contact) {
|
||||
if (contact == null) return displaySender;
|
||||
|
||||
// If contact has roleEmoji, use it with displayName
|
||||
final roleEmoji = contact.roleEmoji;
|
||||
if (roleEmoji != null && roleEmoji.isNotEmpty) {
|
||||
return '$roleEmoji ${contact.displayName}';
|
||||
}
|
||||
|
||||
// Otherwise just use advName or displayName
|
||||
return contact.displayName ?? contact.advName ?? displaySender;
|
||||
}
|
||||
|
||||
/// Convert to SAR marker if applicable
|
||||
/// Convert to a [SarMarker] if this message contains SAR data.
|
||||
SarMarker? toSarMarker() {
|
||||
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Debug: Check what's in sarNotes
|
||||
debugPrint('📍 [Message.toSarMarker] Converting to marker:');
|
||||
debugPrint(' message.text: "$text"');
|
||||
debugPrint(' message.sarNotes: "$sarNotes"');
|
||||
@@ -272,220 +48,9 @@ class Message {
|
||||
timestamp: sentAt,
|
||||
senderPublicKey: senderPublicKeyPrefix,
|
||||
senderName: senderName,
|
||||
notes: sarNotes, // Use dedicated notes field instead of full text
|
||||
customEmoji: sarCustomEmoji, // Preserve custom emoji for unknown types
|
||||
colorIndex: sarColorIndex, // Pass through color index
|
||||
notes: sarNotes,
|
||||
customEmoji: sarCustomEmoji,
|
||||
colorIndex: sarColorIndex,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get echo status text for channel messages
|
||||
String get echoStatusText {
|
||||
if (!isChannelMessage) return '';
|
||||
|
||||
if (echoCount == 0) {
|
||||
return 'Broadcast (no echoes)';
|
||||
} else if (echoCount == 1) {
|
||||
return 'Rebroadcast by 1 node';
|
||||
} else {
|
||||
return 'Rebroadcast by $echoCount nodes';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get friendly delivery status description
|
||||
String get deliveryStatusText {
|
||||
// For channel messages, show echo status instead
|
||||
if (isChannelMessage && isSentMessage) {
|
||||
return echoStatusText;
|
||||
}
|
||||
|
||||
switch (deliveryStatus) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
if (retryAttempt > 0) {
|
||||
return 'Retrying ($retryAttempt/3)...';
|
||||
}
|
||||
return 'Sending...';
|
||||
|
||||
case MessageDeliveryStatus.sent:
|
||||
if (retryAttempt > 0) {
|
||||
return 'Sent (retry $retryAttempt)';
|
||||
}
|
||||
return 'Sent';
|
||||
|
||||
case MessageDeliveryStatus.delivered:
|
||||
final rttText = roundTripTimeMs != null ? '${roundTripTimeMs}ms' : '';
|
||||
if (retryAttempt > 0 && rttText.isNotEmpty) {
|
||||
return 'Delivered ($rttText) [retry $retryAttempt]';
|
||||
} else if (retryAttempt > 0) {
|
||||
return 'Delivered [retry $retryAttempt]';
|
||||
} else if (rttText.isNotEmpty) {
|
||||
return 'Delivered ($rttText)';
|
||||
}
|
||||
return 'Delivered';
|
||||
|
||||
case MessageDeliveryStatus.failed:
|
||||
if (usedFloodFallback) {
|
||||
return 'Failed (tried flood)';
|
||||
}
|
||||
if (retryAttempt > 0) {
|
||||
final retryWord = retryAttempt == 1 ? 'retry' : 'retries';
|
||||
return 'Failed (after $retryAttempt $retryWord)';
|
||||
}
|
||||
return 'Failed';
|
||||
|
||||
case MessageDeliveryStatus.received:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a sent message (not received)
|
||||
bool get isSentMessage => deliveryStatus != MessageDeliveryStatus.received;
|
||||
|
||||
/// Check if this message is from self (own message)
|
||||
/// [selfPublicKey] - the device's own public key (first 6 bytes)
|
||||
bool isFromSelf(Uint8List? selfPublicKey) {
|
||||
if (selfPublicKey == null || selfPublicKey.length < 6) return false;
|
||||
|
||||
// Compare sender public key prefix with self public key prefix
|
||||
if (senderPublicKeyPrefix != null && senderPublicKeyPrefix!.length >= 6) {
|
||||
return senderPublicKeyPrefix![0] == selfPublicKey[0] &&
|
||||
senderPublicKeyPrefix![1] == selfPublicKey[1] &&
|
||||
senderPublicKeyPrefix![2] == selfPublicKey[2] &&
|
||||
senderPublicKeyPrefix![3] == selfPublicKey[3] &&
|
||||
senderPublicKeyPrefix![4] == selfPublicKey[4] &&
|
||||
senderPublicKeyPrefix![5] == selfPublicKey[5];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Get drawing metadata from message text (returns null if not a drawing)
|
||||
/// Extracts basic info for display in message bubbles
|
||||
Map<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';
|
||||
|
||||
/// 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)';
|
||||
}
|
||||
}
|
||||
export 'package:meshcore_client/meshcore_client.dart' show SentMessageTracker;
|
||||
|
||||
Reference in New Issue
Block a user