|
|
|
|
@ -29,6 +29,7 @@ import '../storage/contact_store.dart';
|
|
|
|
|
import '../storage/message_store.dart';
|
|
|
|
|
import '../storage/unread_store.dart';
|
|
|
|
|
import '../utils/app_logger.dart';
|
|
|
|
|
import '../utils/battery_utils.dart';
|
|
|
|
|
import 'meshcore_protocol.dart';
|
|
|
|
|
|
|
|
|
|
class MeshCoreUuids {
|
|
|
|
|
@ -37,6 +38,42 @@ class MeshCoreUuids {
|
|
|
|
|
static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class DirectRepeater {
|
|
|
|
|
static const int maxAgeMinutes = 30; // Max age for direct repeater info
|
|
|
|
|
final int pubkeyFirstByte;
|
|
|
|
|
double snr;
|
|
|
|
|
DateTime lastUpdated;
|
|
|
|
|
|
|
|
|
|
DirectRepeater({
|
|
|
|
|
required this.pubkeyFirstByte,
|
|
|
|
|
required this.snr,
|
|
|
|
|
DateTime? lastUpdated,
|
|
|
|
|
}) : lastUpdated = lastUpdated ?? DateTime.now();
|
|
|
|
|
|
|
|
|
|
void update(double newSNR) {
|
|
|
|
|
snr = newSNR;
|
|
|
|
|
lastUpdated = DateTime.now();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int get ranking {
|
|
|
|
|
if (isStale()) {
|
|
|
|
|
return -1; // Stale repeaters get lowest rank
|
|
|
|
|
}
|
|
|
|
|
// Higher SNR gets higher rank and recency within maxAgeMinutes breaks ties.
|
|
|
|
|
final ageMs =
|
|
|
|
|
DateTime.now().millisecondsSinceEpoch -
|
|
|
|
|
lastUpdated.millisecondsSinceEpoch;
|
|
|
|
|
final maxAgeMs = maxAgeMinutes * 60 * 1000;
|
|
|
|
|
final recencyScore = (maxAgeMs - ageMs).clamp(0, maxAgeMs);
|
|
|
|
|
return ((snr - 31.75) * 1000).round() + recencyScore;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool isStale() {
|
|
|
|
|
return DateTime.now().difference(lastUpdated) >
|
|
|
|
|
const Duration(minutes: maxAgeMinutes);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enum MeshCoreConnectionState {
|
|
|
|
|
disconnected,
|
|
|
|
|
scanning,
|
|
|
|
|
@ -45,6 +82,18 @@ enum MeshCoreConnectionState {
|
|
|
|
|
disconnecting,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class RepeaterBatterySnapshot {
|
|
|
|
|
final int millivolts;
|
|
|
|
|
final DateTime updatedAt;
|
|
|
|
|
final String source;
|
|
|
|
|
|
|
|
|
|
const RepeaterBatterySnapshot({
|
|
|
|
|
required this.millivolts,
|
|
|
|
|
required this.updatedAt,
|
|
|
|
|
required this.source,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
// Message windowing to limit memory usage
|
|
|
|
|
static const int _messageWindowSize = 200;
|
|
|
|
|
@ -65,6 +114,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
final List<Channel> _channels = [];
|
|
|
|
|
final Map<String, List<Message>> _conversations = {};
|
|
|
|
|
final Map<int, List<ChannelMessage>> _channelMessages = {};
|
|
|
|
|
final List<String> _pendingChannelSentQueue = [];
|
|
|
|
|
final List<_PendingCommandAck> _pendingGenericAckQueue = [];
|
|
|
|
|
static const String _reactionSendQueuePrefix = '__reaction_send__';
|
|
|
|
|
int _reactionSendQueueSequence = 0;
|
|
|
|
|
final Set<String> _loadedConversationKeys = {};
|
|
|
|
|
final Map<int, Set<String>> _processedChannelReactions =
|
|
|
|
|
{}; // channelIndex -> Set of "targetHash_emoji"
|
|
|
|
|
@ -95,6 +148,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
int? _batteryMillivolts;
|
|
|
|
|
double? _selfLatitude;
|
|
|
|
|
double? _selfLongitude;
|
|
|
|
|
final List<DirectRepeater> _directRepeaters = List.empty(growable: true);
|
|
|
|
|
bool _isLoadingContacts = false;
|
|
|
|
|
bool _isLoadingChannels = false;
|
|
|
|
|
bool _hasLoadedChannels = false;
|
|
|
|
|
@ -150,6 +204,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
final Map<String, bool> _contactSmazEnabled = {};
|
|
|
|
|
final Set<String> _knownContactKeys = {};
|
|
|
|
|
final Map<String, int> _contactUnreadCount = {};
|
|
|
|
|
final Map<String, RepeaterBatterySnapshot> _repeaterBatterySnapshots = {};
|
|
|
|
|
bool _unreadStateLoaded = false;
|
|
|
|
|
final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {};
|
|
|
|
|
String? _activeContactKey;
|
|
|
|
|
@ -196,6 +251,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
String? get selfName => _selfName;
|
|
|
|
|
double? get selfLatitude => _selfLatitude;
|
|
|
|
|
double? get selfLongitude => _selfLongitude;
|
|
|
|
|
List<DirectRepeater> get directRepeaters => _directRepeaters;
|
|
|
|
|
int? get currentTxPower => _currentTxPower;
|
|
|
|
|
int? get maxTxPower => _maxTxPower;
|
|
|
|
|
int? get currentFreqHz => _currentFreqHz;
|
|
|
|
|
@ -216,10 +272,32 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
: 0;
|
|
|
|
|
int? get batteryPercent => _batteryMillivolts == null
|
|
|
|
|
? null
|
|
|
|
|
: _estimateBatteryPercent(
|
|
|
|
|
: estimateBatteryPercentFromMillivolts(
|
|
|
|
|
_batteryMillivolts!,
|
|
|
|
|
_batteryChemistryForDevice(),
|
|
|
|
|
);
|
|
|
|
|
RepeaterBatterySnapshot? getRepeaterBatterySnapshot(String contactKeyHex) =>
|
|
|
|
|
_repeaterBatterySnapshots[contactKeyHex];
|
|
|
|
|
int? getRepeaterBatteryMillivolts(String contactKeyHex) =>
|
|
|
|
|
_repeaterBatterySnapshots[contactKeyHex]?.millivolts;
|
|
|
|
|
|
|
|
|
|
void updateRepeaterBatterySnapshot(
|
|
|
|
|
String contactKeyHex,
|
|
|
|
|
int millivolts, {
|
|
|
|
|
String source = 'unknown',
|
|
|
|
|
}) {
|
|
|
|
|
if (contactKeyHex.isEmpty || millivolts <= 0) return;
|
|
|
|
|
final previous = _repeaterBatterySnapshots[contactKeyHex];
|
|
|
|
|
final snapshot = RepeaterBatterySnapshot(
|
|
|
|
|
millivolts: millivolts,
|
|
|
|
|
updatedAt: DateTime.now(),
|
|
|
|
|
source: source,
|
|
|
|
|
);
|
|
|
|
|
_repeaterBatterySnapshots[contactKeyHex] = snapshot;
|
|
|
|
|
if (previous?.millivolts != millivolts) {
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _batteryChemistryForDevice() {
|
|
|
|
|
final deviceId = _device?.remoteId.toString();
|
|
|
|
|
@ -227,27 +305,6 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
return _appSettingsService!.batteryChemistryForDevice(deviceId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int _estimateBatteryPercent(int millivolts, String chemistry) {
|
|
|
|
|
final range = _batteryVoltageRange(chemistry);
|
|
|
|
|
final minMv = range.$1;
|
|
|
|
|
final maxMv = range.$2;
|
|
|
|
|
if (millivolts <= minMv) return 0;
|
|
|
|
|
if (millivolts >= maxMv) return 100;
|
|
|
|
|
return (((millivolts - minMv) * 100) / (maxMv - minMv)).round();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
(int, int) _batteryVoltageRange(String chemistry) {
|
|
|
|
|
switch (chemistry) {
|
|
|
|
|
case 'lifepo4':
|
|
|
|
|
return (2600, 3650);
|
|
|
|
|
case 'lipo':
|
|
|
|
|
return (3000, 4200);
|
|
|
|
|
case 'nmc':
|
|
|
|
|
default:
|
|
|
|
|
return (3000, 4200);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
List<Message> getMessages(Contact contact) {
|
|
|
|
|
return _conversations[contact.publicKeyHex] ?? [];
|
|
|
|
|
}
|
|
|
|
|
@ -923,6 +980,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
_clientRepeat = null;
|
|
|
|
|
_firmwareVerCode = null;
|
|
|
|
|
_batteryMillivolts = null;
|
|
|
|
|
_repeaterBatterySnapshots.clear();
|
|
|
|
|
_batteryRequested = false;
|
|
|
|
|
_awaitingSelfInfo = false;
|
|
|
|
|
_maxContacts = _defaultMaxContacts;
|
|
|
|
|
@ -934,6 +992,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
_isSyncingChannels = false;
|
|
|
|
|
_channelSyncInFlight = false;
|
|
|
|
|
_hasLoadedChannels = false;
|
|
|
|
|
_pendingChannelSentQueue.clear();
|
|
|
|
|
_pendingGenericAckQueue.clear();
|
|
|
|
|
_reactionSendQueueSequence = 0;
|
|
|
|
|
|
|
|
|
|
_setState(MeshCoreConnectionState.disconnected);
|
|
|
|
|
if (!manual) {
|
|
|
|
|
@ -941,7 +1002,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> sendFrame(Uint8List data) async {
|
|
|
|
|
Future<void> sendFrame(
|
|
|
|
|
Uint8List data, {
|
|
|
|
|
String? channelSendQueueId,
|
|
|
|
|
bool expectsGenericAck = false,
|
|
|
|
|
}) async {
|
|
|
|
|
if (!isConnected || _rxCharacteristic == null) {
|
|
|
|
|
throw Exception("Not connected to a MeshCore device");
|
|
|
|
|
}
|
|
|
|
|
@ -960,6 +1025,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
data.toList(),
|
|
|
|
|
withoutResponse: canWriteWithoutResponse,
|
|
|
|
|
);
|
|
|
|
|
_trackPendingGenericAck(
|
|
|
|
|
data,
|
|
|
|
|
channelSendQueueId: channelSendQueueId,
|
|
|
|
|
expectsGenericAck: expectsGenericAck,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> requestBatteryStatus({bool force = false}) async {
|
|
|
|
|
@ -1315,7 +1385,13 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
// Send the reaction to the device (don't add as a visible message)
|
|
|
|
|
await sendFrame(buildSendChannelTextMsgFrame(channel.index, text));
|
|
|
|
|
final reactionQueueId = _nextReactionSendQueueId();
|
|
|
|
|
_pendingChannelSentQueue.add(reactionQueueId);
|
|
|
|
|
await sendFrame(
|
|
|
|
|
buildSendChannelTextMsgFrame(channel.index, text),
|
|
|
|
|
channelSendQueueId: reactionQueueId,
|
|
|
|
|
expectsGenericAck: true,
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -1325,6 +1401,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
channel.index,
|
|
|
|
|
);
|
|
|
|
|
_addChannelMessage(channel.index, message);
|
|
|
|
|
_pendingChannelSentQueue.add(message.messageId);
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
final trimmed = text.trim();
|
|
|
|
|
@ -1334,7 +1411,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
(isChannelSmazEnabled(channel.index) && !isStructuredPayload)
|
|
|
|
|
? Smaz.encodeIfSmaller(text)
|
|
|
|
|
: text;
|
|
|
|
|
await sendFrame(buildSendChannelTextMsgFrame(channel.index, outboundText));
|
|
|
|
|
await sendFrame(
|
|
|
|
|
buildSendChannelTextMsgFrame(channel.index, outboundText),
|
|
|
|
|
channelSendQueueId: message.messageId,
|
|
|
|
|
expectsGenericAck: true,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> removeContact(Contact contact) async {
|
|
|
|
|
@ -1681,6 +1762,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
debugPrint('RX frame: code=$code len=${frame.length}');
|
|
|
|
|
|
|
|
|
|
switch (code) {
|
|
|
|
|
case respCodeOk:
|
|
|
|
|
_handleOk();
|
|
|
|
|
break;
|
|
|
|
|
case respCodeDeviceInfo:
|
|
|
|
|
_handleDeviceInfo(frame);
|
|
|
|
|
break;
|
|
|
|
|
@ -1696,6 +1780,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
_isLoadingContacts = true;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
break;
|
|
|
|
|
case pushCodeNewAdvert:
|
|
|
|
|
debugPrint('Got New CONTACT');
|
|
|
|
|
// It's the same format as respCodeContact, so we can reuse the handler
|
|
|
|
|
_handleContact(frame);
|
|
|
|
|
break;
|
|
|
|
|
case respCodeContact:
|
|
|
|
|
debugPrint('Got CONTACT');
|
|
|
|
|
_handleContact(frame);
|
|
|
|
|
@ -1740,6 +1829,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
case pushCodeStatusResponse:
|
|
|
|
|
break;
|
|
|
|
|
case pushCodeLogRxData:
|
|
|
|
|
_handleRxData(frame);
|
|
|
|
|
_handleLogRxData(frame);
|
|
|
|
|
break;
|
|
|
|
|
case respCodeChannelInfo:
|
|
|
|
|
@ -1769,6 +1859,17 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
'Firmware responded with error code: $errCode',
|
|
|
|
|
tag: 'Protocol',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (_pendingGenericAckQueue.isEmpty) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final failedAck = _pendingGenericAckQueue.removeAt(0);
|
|
|
|
|
if (failedAck.commandCode != cmdSendChannelTxtMsg ||
|
|
|
|
|
failedAck.channelSendQueueId == null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_pendingChannelSentQueue.remove(failedAck.channelSendQueueId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handlePathUpdated(Uint8List frame) {
|
|
|
|
|
@ -2028,6 +2129,80 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleContactAdvert(Contact contact) {
|
|
|
|
|
if (listEquals(contact.publicKey, _selfPublicKey)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (contact.type == advTypeRepeater) {
|
|
|
|
|
_contactUnreadCount.remove(contact.publicKeyHex);
|
|
|
|
|
_unreadStore.saveContactUnreadCount(
|
|
|
|
|
Map<String, int>.from(_contactUnreadCount),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// Check if this is a new contact
|
|
|
|
|
final isNewContact = !_knownContactKeys.contains(contact.publicKeyHex);
|
|
|
|
|
final existingIndex = _contacts.indexWhere(
|
|
|
|
|
(c) => c.publicKeyHex == contact.publicKeyHex,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (existingIndex >= 0) {
|
|
|
|
|
final existing = _contacts[existingIndex];
|
|
|
|
|
final mergedLastMessageAt =
|
|
|
|
|
existing.lastMessageAt.isAfter(contact.lastMessageAt)
|
|
|
|
|
? existing.lastMessageAt
|
|
|
|
|
: contact.lastMessageAt;
|
|
|
|
|
|
|
|
|
|
appLogger.info(
|
|
|
|
|
'Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}',
|
|
|
|
|
tag: 'Connector',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// CRITICAL: Preserve user's path override when contact is refreshed from device
|
|
|
|
|
_contacts[existingIndex] = contact.copyWith(
|
|
|
|
|
lastMessageAt: mergedLastMessageAt,
|
|
|
|
|
pathOverride: existing.pathOverride, // Preserve user's path choice
|
|
|
|
|
pathOverrideBytes: existing.pathOverrideBytes,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
appLogger.info(
|
|
|
|
|
'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}',
|
|
|
|
|
tag: 'Connector',
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
_contacts.add(contact);
|
|
|
|
|
appLogger.info(
|
|
|
|
|
'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
|
|
|
|
|
tag: 'Connector',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
_knownContactKeys.add(contact.publicKeyHex);
|
|
|
|
|
_loadMessagesForContact(contact.publicKeyHex);
|
|
|
|
|
|
|
|
|
|
// Add path to history if we have a valid path
|
|
|
|
|
if (_pathHistoryService != null && contact.pathLength >= 0) {
|
|
|
|
|
_pathHistoryService!.handlePathUpdated(contact);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
// Show notification for new contact (advertisement)
|
|
|
|
|
if (isNewContact && _appSettingsService != null) {
|
|
|
|
|
final settings = _appSettingsService!.settings;
|
|
|
|
|
if (settings.notificationsEnabled && settings.notifyOnNewAdvert) {
|
|
|
|
|
_notificationService.showAdvertNotification(
|
|
|
|
|
contactName: contact.name,
|
|
|
|
|
contactType: contact.typeLabel,
|
|
|
|
|
contactId: contact.publicKeyHex,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!_isLoadingContacts) {
|
|
|
|
|
unawaited(_persistContacts());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _persistContacts() async {
|
|
|
|
|
await _contactStore.saveContacts(_contacts);
|
|
|
|
|
}
|
|
|
|
|
@ -2354,6 +2529,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final label = channelName ?? _channelDisplayName(channelIndex);
|
|
|
|
|
if (_appSettingsService!.isChannelMuted(label)) return;
|
|
|
|
|
|
|
|
|
|
_notificationService.showChannelMessageNotification(
|
|
|
|
|
channelName: label,
|
|
|
|
|
message: message.text,
|
|
|
|
|
@ -2477,8 +2654,22 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (_retryService != null) {
|
|
|
|
|
_retryService!.updateMessageFromSent(ackHash, timeoutMs);
|
|
|
|
|
final retryService = _retryService;
|
|
|
|
|
if (retryService != null &&
|
|
|
|
|
retryService.updateMessageFromSent(
|
|
|
|
|
ackHash,
|
|
|
|
|
timeoutMs,
|
|
|
|
|
allowQueueFallback: false,
|
|
|
|
|
)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (_markNextPendingChannelMessageSent()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (retryService != null) {
|
|
|
|
|
retryService.updateMessageFromSent(ackHash, timeoutMs);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Fallback to old behavior
|
|
|
|
|
@ -2495,6 +2686,64 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool _markNextPendingChannelMessageSent() {
|
|
|
|
|
while (_pendingChannelSentQueue.isNotEmpty) {
|
|
|
|
|
final queuedMessageId = _pendingChannelSentQueue.removeAt(0);
|
|
|
|
|
if (_isReactionSendQueueId(queuedMessageId)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (_markPendingChannelMessageSentById(queuedMessageId)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool _markPendingChannelMessageSentById(String messageId) {
|
|
|
|
|
for (final entry in _channelMessages.entries) {
|
|
|
|
|
final channelMessages = entry.value;
|
|
|
|
|
for (int i = channelMessages.length - 1; i >= 0; i--) {
|
|
|
|
|
final message = channelMessages[i];
|
|
|
|
|
if (message.messageId != messageId) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!message.isOutgoing ||
|
|
|
|
|
message.status != ChannelMessageStatus.pending) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
channelMessages[i] = message.copyWith(
|
|
|
|
|
status: ChannelMessageStatus.sent,
|
|
|
|
|
);
|
|
|
|
|
_pendingChannelSentQueue.remove(messageId);
|
|
|
|
|
unawaited(
|
|
|
|
|
_channelMessageStore.saveChannelMessages(entry.key, channelMessages),
|
|
|
|
|
);
|
|
|
|
|
notifyListeners();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleOk() {
|
|
|
|
|
if (_pendingGenericAckQueue.isEmpty) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final pendingAck = _pendingGenericAckQueue.removeAt(0);
|
|
|
|
|
if (pendingAck.commandCode != cmdSendChannelTxtMsg ||
|
|
|
|
|
pendingAck.channelSendQueueId == null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final queueId = pendingAck.channelSendQueueId!;
|
|
|
|
|
_pendingChannelSentQueue.remove(queueId);
|
|
|
|
|
if (_isReactionSendQueueId(queueId)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_markPendingChannelMessageSentById(queueId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleSendConfirmed(Uint8List frame) {
|
|
|
|
|
// Frame format from C++:
|
|
|
|
|
// [0] = PUSH_CODE_SEND_CONFIRMED
|
|
|
|
|
@ -3073,18 +3322,22 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
mergedPathBytes.length,
|
|
|
|
|
);
|
|
|
|
|
final newRepeatCount = existing.repeatCount + 1;
|
|
|
|
|
final promotedFromPending =
|
|
|
|
|
newRepeatCount == 1 &&
|
|
|
|
|
existing.status == ChannelMessageStatus.pending;
|
|
|
|
|
messages[existingIndex] = existing.copyWith(
|
|
|
|
|
repeatCount: newRepeatCount,
|
|
|
|
|
pathLength: mergedPathLength,
|
|
|
|
|
pathBytes: mergedPathBytes,
|
|
|
|
|
pathVariants: mergedPathVariants,
|
|
|
|
|
// Mark as sent when first repeat is heard
|
|
|
|
|
status:
|
|
|
|
|
newRepeatCount == 1 &&
|
|
|
|
|
existing.status == ChannelMessageStatus.pending
|
|
|
|
|
status: promotedFromPending
|
|
|
|
|
? ChannelMessageStatus.sent
|
|
|
|
|
: existing.status,
|
|
|
|
|
);
|
|
|
|
|
if (promotedFromPending) {
|
|
|
|
|
_pendingChannelSentQueue.remove(existing.messageId);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
messages.add(processedMessage);
|
|
|
|
|
}
|
|
|
|
|
@ -3257,11 +3510,37 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
_queuedMessageSyncInFlight = false;
|
|
|
|
|
_isSyncingChannels = false;
|
|
|
|
|
_channelSyncInFlight = false;
|
|
|
|
|
_pendingChannelSentQueue.clear();
|
|
|
|
|
_pendingGenericAckQueue.clear();
|
|
|
|
|
_reactionSendQueueSequence = 0;
|
|
|
|
|
|
|
|
|
|
_setState(MeshCoreConnectionState.disconnected);
|
|
|
|
|
_scheduleReconnect();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _trackPendingGenericAck(
|
|
|
|
|
Uint8List data, {
|
|
|
|
|
String? channelSendQueueId,
|
|
|
|
|
required bool expectsGenericAck,
|
|
|
|
|
}) {
|
|
|
|
|
if (!expectsGenericAck || data.isEmpty) return;
|
|
|
|
|
_pendingGenericAckQueue.add(
|
|
|
|
|
_PendingCommandAck(
|
|
|
|
|
commandCode: data[0],
|
|
|
|
|
channelSendQueueId: channelSendQueueId,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _nextReactionSendQueueId() {
|
|
|
|
|
_reactionSendQueueSequence++;
|
|
|
|
|
return '$_reactionSendQueuePrefix$_reactionSendQueueSequence';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool _isReactionSendQueueId(String queueId) {
|
|
|
|
|
return queueId.startsWith(_reactionSendQueuePrefix);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Map<String, String> _parseKeyValueString(String input) {
|
|
|
|
|
final result = <String, String>{};
|
|
|
|
|
|
|
|
|
|
@ -3287,7 +3566,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
|
|
|
|
|
void _handleCustomVars(Uint8List frame) {
|
|
|
|
|
final buf = BufferReader(frame.sublist(1));
|
|
|
|
|
_currentCustomVars = _parseKeyValueString(buf.readString());
|
|
|
|
|
try {
|
|
|
|
|
_currentCustomVars = _parseKeyValueString(buf.readString());
|
|
|
|
|
} catch (e) {
|
|
|
|
|
appLogger.warn('Malformed custom vars frame: $e', tag: 'Connector');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _setState(MeshCoreConnectionState newState) {
|
|
|
|
|
@ -3311,6 +3594,191 @@ class MeshCoreConnector extends ChangeNotifier {
|
|
|
|
|
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleRxData(Uint8List frame) {
|
|
|
|
|
final packet = BufferReader(frame);
|
|
|
|
|
double snr = 0.0;
|
|
|
|
|
int routeType = 0;
|
|
|
|
|
int payloadType = 0;
|
|
|
|
|
Uint8List pathBytes = Uint8List(0);
|
|
|
|
|
Uint8List payload = Uint8List(0);
|
|
|
|
|
try {
|
|
|
|
|
packet.skipBytes(1); // Skip frame type byte
|
|
|
|
|
snr = packet.readInt8() / 4.0;
|
|
|
|
|
packet.skipBytes(1); // Skip RSSI byte
|
|
|
|
|
//final rssi = packet.readByte();
|
|
|
|
|
final header = packet.readByte();
|
|
|
|
|
routeType = header & 0x03;
|
|
|
|
|
payloadType = (header >> 2) & 0x0F;
|
|
|
|
|
//final payloadVer = (header >> 6) & 0x03;
|
|
|
|
|
final pathLen = packet.readByte();
|
|
|
|
|
pathBytes = packet.readBytes(pathLen);
|
|
|
|
|
payload = packet.readBytes(packet.remaining);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (payloadType) {
|
|
|
|
|
case payloadTypeADVERT:
|
|
|
|
|
_handlePayloadAdvertReceived(payload, pathBytes, routeType, snr);
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handlePayloadAdvertReceived(
|
|
|
|
|
Uint8List frame,
|
|
|
|
|
Uint8List path,
|
|
|
|
|
int routeType,
|
|
|
|
|
double snr,
|
|
|
|
|
) {
|
|
|
|
|
final advert = BufferReader(frame);
|
|
|
|
|
double latitude = 0.0;
|
|
|
|
|
double longitude = 0.0;
|
|
|
|
|
String name = '';
|
|
|
|
|
String contactKeyHex = '';
|
|
|
|
|
Uint8List publicKey = Uint8List(0);
|
|
|
|
|
int type = 0;
|
|
|
|
|
int timestamp = 0;
|
|
|
|
|
bool hasLocation = false;
|
|
|
|
|
bool hasName = false;
|
|
|
|
|
try {
|
|
|
|
|
publicKey = advert.readBytes(32);
|
|
|
|
|
contactKeyHex = publicKey
|
|
|
|
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
|
|
|
.join();
|
|
|
|
|
|
|
|
|
|
timestamp = advert.readInt32LE();
|
|
|
|
|
//TODO add signature verification
|
|
|
|
|
advert.skipBytes(64); // Skip signature for now
|
|
|
|
|
final flags = advert.readByte();
|
|
|
|
|
type = flags & 0x0F;
|
|
|
|
|
hasLocation = (flags & 0x10) != 0;
|
|
|
|
|
// For future use:
|
|
|
|
|
//final hasFeature1 = (flags & 0x20) != 0;
|
|
|
|
|
//final hasFeature2 = (flags & 0x40) != 0;
|
|
|
|
|
hasName = (flags & 0x80) != 0;
|
|
|
|
|
if (hasLocation && advert.remaining >= 8) {
|
|
|
|
|
latitude = advert.readInt32LE() / 1e6;
|
|
|
|
|
longitude = advert.readInt32LE() / 1e6;
|
|
|
|
|
}
|
|
|
|
|
if (hasName && advert.remaining > 0) {
|
|
|
|
|
name = advert.readString();
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
appLogger.warn('Malformed advert frame: $e', tag: 'Connector');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (listEquals(publicKey, _selfPublicKey)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if this is a new contact
|
|
|
|
|
final isNewContact = !_knownContactKeys.contains(contactKeyHex);
|
|
|
|
|
|
|
|
|
|
if (isNewContact) {
|
|
|
|
|
final newContact = Contact(
|
|
|
|
|
publicKey: publicKey,
|
|
|
|
|
name: name,
|
|
|
|
|
type: type,
|
|
|
|
|
pathLength: path.length,
|
|
|
|
|
path: Uint8List.fromList(
|
|
|
|
|
path.reversed.toList(),
|
|
|
|
|
), // Store path in reverse for easier use in outgoing messages
|
|
|
|
|
latitude: latitude,
|
|
|
|
|
longitude: longitude,
|
|
|
|
|
lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
|
|
|
|
|
);
|
|
|
|
|
_handleContactAdvert(newContact);
|
|
|
|
|
_updateDirectRepeater(newContact, snr, path);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final existingIndex = _contacts.indexWhere(
|
|
|
|
|
(c) => c.publicKeyHex == contactKeyHex,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (existingIndex >= 0) {
|
|
|
|
|
final existing = _contacts[existingIndex];
|
|
|
|
|
final mergedLastMessageAt = existing.lastMessageAt.isAfter(DateTime.now())
|
|
|
|
|
? DateTime.now()
|
|
|
|
|
: existing.lastMessageAt;
|
|
|
|
|
|
|
|
|
|
appLogger.info(
|
|
|
|
|
'Refreshing contact ${existing.name}: devicePath=${existing.pathLength}, existingOverride=${existing.pathOverride}',
|
|
|
|
|
tag: 'Connector',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// CRITICAL: Preserve user's path override when contact is refreshed from device
|
|
|
|
|
_contacts[existingIndex] = existing.copyWith(
|
|
|
|
|
latitude: hasLocation ? latitude : existing.latitude,
|
|
|
|
|
longitude: hasLocation ? longitude : existing.longitude,
|
|
|
|
|
name: hasName ? name : existing.name,
|
|
|
|
|
path: Uint8List.fromList(path.reversed.toList()),
|
|
|
|
|
pathLength: path.length,
|
|
|
|
|
lastMessageAt: mergedLastMessageAt,
|
|
|
|
|
lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
|
|
|
|
|
pathOverride: existing.pathOverride, // Preserve user's path choice
|
|
|
|
|
pathOverrideBytes: existing.pathOverrideBytes,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Add path to history if we have a valid path
|
|
|
|
|
if (_pathHistoryService != null &&
|
|
|
|
|
_contacts[existingIndex].pathLength >= 0) {
|
|
|
|
|
_pathHistoryService!.handlePathUpdated(_contacts[existingIndex]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_updateDirectRepeater(_contacts[existingIndex], snr, path);
|
|
|
|
|
|
|
|
|
|
appLogger.info(
|
|
|
|
|
'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}',
|
|
|
|
|
tag: 'Connector',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _updateDirectRepeater(Contact contact, double snr, Uint8List path) {
|
|
|
|
|
final pubkeyFirstByte = path.isNotEmpty
|
|
|
|
|
? path.last
|
|
|
|
|
: contact.publicKey.first;
|
|
|
|
|
|
|
|
|
|
_directRepeaters.removeWhere((r) => r.isStale());
|
|
|
|
|
|
|
|
|
|
//We can use adverts from chat and sensor nodes, but only if the advert has a path to get the last hop.
|
|
|
|
|
if ((contact.type == advTypeChat || contact.type == advTypeSensor) &&
|
|
|
|
|
path.isEmpty) {
|
|
|
|
|
notifyListeners();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final isTracked = _directRepeaters.where(
|
|
|
|
|
(r) => r.pubkeyFirstByte == pubkeyFirstByte,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final sortedRepeaters = List<DirectRepeater>.from(_directRepeaters)
|
|
|
|
|
..sort((a, b) => b.snr.compareTo(a.snr));
|
|
|
|
|
final weakestRepeater = sortedRepeaters.isNotEmpty
|
|
|
|
|
? sortedRepeaters.last
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
if (_directRepeaters.length >= 5 &&
|
|
|
|
|
weakestRepeater != null &&
|
|
|
|
|
isTracked.isEmpty) {
|
|
|
|
|
_directRepeaters.remove(weakestRepeater);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isTracked.isNotEmpty) {
|
|
|
|
|
final repeater = isTracked.first;
|
|
|
|
|
repeater.update(snr);
|
|
|
|
|
} else if (_directRepeaters.length < 5) {
|
|
|
|
|
_directRepeaters.add(
|
|
|
|
|
DirectRepeater(pubkeyFirstByte: pubkeyFirstByte, snr: snr),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const int _phRouteMask = 0x03;
|
|
|
|
|
@ -3368,3 +3836,10 @@ class _RepeaterAckContext {
|
|
|
|
|
required this.messageBytes,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _PendingCommandAck {
|
|
|
|
|
final int commandCode;
|
|
|
|
|
final String? channelSendQueueId;
|
|
|
|
|
|
|
|
|
|
_PendingCommandAck({required this.commandCode, this.channelSendQueueId});
|
|
|
|
|
}
|
|
|
|
|
|