mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-28 13:17:35 +10:00
Merge branch 'main' into unread-peoplefirst
This commit is contained in:
@@ -5,7 +5,6 @@ import 'package:crypto/crypto.dart' as crypto;
|
||||
import 'package:pointycastle/export.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../models/channel.dart';
|
||||
import '../models/channel_message.dart';
|
||||
@@ -30,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 {
|
||||
@@ -38,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,
|
||||
@@ -46,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;
|
||||
@@ -66,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"
|
||||
@@ -91,9 +143,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
int? _currentBwHz;
|
||||
int? _currentSf;
|
||||
int? _currentCr;
|
||||
bool? _clientRepeat;
|
||||
int? _firmwareVerCode;
|
||||
int? _batteryMillivolts;
|
||||
double? _selfLatitude;
|
||||
double? _selfLongitude;
|
||||
final List<DirectRepeater> _directRepeaters = List.empty(growable: true);
|
||||
bool _isLoadingContacts = false;
|
||||
bool _isLoadingChannels = false;
|
||||
bool _hasLoadedChannels = false;
|
||||
@@ -149,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;
|
||||
int _cachedContactsUnreadTotal = 0;
|
||||
int _cachedChannelsUnreadTotal = 0;
|
||||
@@ -197,12 +253,15 @@ 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;
|
||||
int? get currentBwHz => _currentBwHz;
|
||||
int? get currentSf => _currentSf;
|
||||
int? get currentCr => _currentCr;
|
||||
bool? get clientRepeat => _clientRepeat;
|
||||
int? get firmwareVerCode => _firmwareVerCode;
|
||||
Map<String, String>? get currentCustomVars => _currentCustomVars;
|
||||
int? get batteryMillivolts => _batteryMillivolts;
|
||||
int get maxContacts => _maxContacts;
|
||||
@@ -215,10 +274,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();
|
||||
@@ -226,27 +307,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] ?? [];
|
||||
}
|
||||
@@ -638,6 +698,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
publicKey: contact.publicKey,
|
||||
name: contact.name,
|
||||
type: contact.type,
|
||||
flags: contact.flags,
|
||||
pathLength: selection.hopCount >= 0
|
||||
? selection.hopCount
|
||||
: contact.pathLength,
|
||||
@@ -687,7 +748,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_scanResults.clear();
|
||||
for (var result in results) {
|
||||
if (result.device.platformName.startsWith("MeshCore-") ||
|
||||
result.advertisementData.advName.startsWith("MeshCore-")) {
|
||||
result.advertisementData.advName.startsWith("MeshCore-") ||
|
||||
result.advertisementData.advName.startsWith("Whisper-")) {
|
||||
_scanResults.add(result);
|
||||
}
|
||||
}
|
||||
@@ -804,9 +866,6 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
_setState(MeshCoreConnectionState.connected);
|
||||
|
||||
// Enable wake lock to prevent BLE disconnection when screen turns off
|
||||
await WakelockPlus.enable();
|
||||
|
||||
await _requestDeviceInfo();
|
||||
_startBatteryPolling();
|
||||
final gotSelfInfo = await _waitForSelfInfo(
|
||||
@@ -915,9 +974,6 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_setState(MeshCoreConnectionState.disconnecting);
|
||||
_stopBatteryPolling();
|
||||
|
||||
// Disable wake lock when disconnecting
|
||||
await WakelockPlus.disable();
|
||||
|
||||
await _notifySubscription?.cancel();
|
||||
_notifySubscription = null;
|
||||
|
||||
@@ -951,7 +1007,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_selfName = null;
|
||||
_selfLatitude = null;
|
||||
_selfLongitude = null;
|
||||
_clientRepeat = null;
|
||||
_firmwareVerCode = null;
|
||||
_batteryMillivolts = null;
|
||||
_repeaterBatterySnapshots.clear();
|
||||
_batteryRequested = false;
|
||||
_awaitingSelfInfo = false;
|
||||
_maxContacts = _defaultMaxContacts;
|
||||
@@ -963,6 +1022,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_isSyncingChannels = false;
|
||||
_channelSyncInFlight = false;
|
||||
_hasLoadedChannels = false;
|
||||
_pendingChannelSentQueue.clear();
|
||||
_pendingGenericAckQueue.clear();
|
||||
_reactionSendQueueSequence = 0;
|
||||
|
||||
_setState(MeshCoreConnectionState.disconnected);
|
||||
if (!manual) {
|
||||
@@ -970,7 +1032,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");
|
||||
}
|
||||
@@ -989,6 +1055,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
data.toList(),
|
||||
withoutResponse: canWriteWithoutResponse,
|
||||
);
|
||||
_trackPendingGenericAck(
|
||||
data,
|
||||
channelSendQueueId: channelSendQueueId,
|
||||
expectsGenericAck: expectsGenericAck,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> requestBatteryStatus({bool force = false}) async {
|
||||
@@ -1144,11 +1215,78 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
customPath,
|
||||
pathLen,
|
||||
type: contact.type,
|
||||
flags: contact.flags,
|
||||
name: contact.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setContactFavorite(Contact contact, bool isFavorite) async {
|
||||
if (!isConnected) return;
|
||||
final latestContact =
|
||||
await _fetchContactSnapshotFromDevice(contact.publicKey) ?? contact;
|
||||
final updatedFlags = isFavorite
|
||||
? (latestContact.flags | contactFlagFavorite)
|
||||
: (latestContact.flags & ~contactFlagFavorite);
|
||||
|
||||
await sendFrame(
|
||||
buildUpdateContactPathFrame(
|
||||
latestContact.publicKey,
|
||||
latestContact.path,
|
||||
latestContact.pathLength,
|
||||
type: latestContact.type,
|
||||
flags: updatedFlags,
|
||||
name: latestContact.name,
|
||||
),
|
||||
);
|
||||
|
||||
final index = _contacts.indexWhere(
|
||||
(c) => c.publicKeyHex == contact.publicKeyHex,
|
||||
);
|
||||
if (index >= 0) {
|
||||
_contacts[index] = _contacts[index].copyWith(
|
||||
type: latestContact.type,
|
||||
name: latestContact.name,
|
||||
pathLength: latestContact.pathLength,
|
||||
path: latestContact.path,
|
||||
flags: updatedFlags,
|
||||
);
|
||||
notifyListeners();
|
||||
unawaited(_persistContacts());
|
||||
}
|
||||
}
|
||||
|
||||
Future<Contact?> _fetchContactSnapshotFromDevice(
|
||||
Uint8List pubKey, {
|
||||
Duration timeout = const Duration(seconds: 3),
|
||||
}) async {
|
||||
if (!isConnected) return null;
|
||||
final expectedKeyHex = pubKeyToHex(pubKey);
|
||||
final completer = Completer<Contact?>();
|
||||
|
||||
void finish(Contact? result) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(result);
|
||||
}
|
||||
}
|
||||
|
||||
final subscription = receivedFrames.listen((frame) {
|
||||
if (frame.isEmpty || frame[0] != respCodeContact) return;
|
||||
final parsed = Contact.fromFrame(frame);
|
||||
if (parsed == null || parsed.publicKeyHex != expectedKeyHex) return;
|
||||
finish(parsed);
|
||||
});
|
||||
|
||||
final timer = Timer(timeout, () => finish(null));
|
||||
try {
|
||||
await getContactByKey(pubKey);
|
||||
return await completer.future;
|
||||
} finally {
|
||||
timer.cancel();
|
||||
await subscription.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set path override for a contact (persists across contact refreshes)
|
||||
/// pathLen: -1 = force flood, null = auto (use device path), >= 0 = specific path
|
||||
Future<void> setPathOverride(
|
||||
@@ -1344,7 +1482,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;
|
||||
}
|
||||
|
||||
@@ -1354,6 +1498,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
channel.index,
|
||||
);
|
||||
_addChannelMessage(channel.index, message);
|
||||
_pendingChannelSentQueue.add(message.messageId);
|
||||
notifyListeners();
|
||||
|
||||
final trimmed = text.trim();
|
||||
@@ -1363,7 +1508,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 {
|
||||
@@ -1711,6 +1860,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
debugPrint('RX frame: code=$code len=${frame.length}');
|
||||
|
||||
switch (code) {
|
||||
case respCodeOk:
|
||||
_handleOk();
|
||||
break;
|
||||
case respCodeDeviceInfo:
|
||||
_handleDeviceInfo(frame);
|
||||
break;
|
||||
@@ -1726,6 +1878,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);
|
||||
@@ -1770,6 +1927,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
case pushCodeStatusResponse:
|
||||
break;
|
||||
case pushCodeLogRxData:
|
||||
_handleRxData(frame);
|
||||
_handleLogRxData(frame);
|
||||
break;
|
||||
case respCodeChannelInfo:
|
||||
@@ -1783,11 +1941,35 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
break;
|
||||
case respCodeCustomVars:
|
||||
_handleCustomVars(frame);
|
||||
break;
|
||||
// RESP_CODE_ERR is a defined firmware response (code 1), not an unknown frame.
|
||||
case respCodeErr:
|
||||
_handleErrorFrame(frame);
|
||||
break;
|
||||
default:
|
||||
debugPrint('Unknown frame code: $code');
|
||||
}
|
||||
}
|
||||
|
||||
void _handleErrorFrame(Uint8List frame) {
|
||||
final errCode = frame.length > 1 ? frame[1] : -1;
|
||||
_appDebugLogService?.warn(
|
||||
'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) {
|
||||
// Frame format: [0]=code, [1-32]=pub_key
|
||||
if (frame.length >= 33 && _pathHistoryService != null) {
|
||||
@@ -1856,6 +2038,13 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
void _handleDeviceInfo(Uint8List frame) {
|
||||
if (frame.length < 4) return;
|
||||
_firmwareVerCode = frame[1];
|
||||
|
||||
// Parse client_repeat from firmware v9+ (byte 80)
|
||||
if (frame.length >= 81) {
|
||||
_clientRepeat = frame[80] != 0;
|
||||
}
|
||||
|
||||
// Firmware reports MAX_CONTACTS / 2 for v3+ device info.
|
||||
final reportedContacts = frame[2];
|
||||
final reportedChannels = frame[3];
|
||||
@@ -1876,8 +2065,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
unawaited(getChannels(maxChannels: nextMaxChannels));
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleNoMoreMessages() {
|
||||
@@ -2038,6 +2227,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);
|
||||
}
|
||||
@@ -2364,6 +2627,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}
|
||||
|
||||
final label = channelName ?? _channelDisplayName(channelIndex);
|
||||
if (_appSettingsService!.isChannelMuted(label)) return;
|
||||
|
||||
_notificationService.showChannelMessageNotification(
|
||||
channelName: label,
|
||||
message: message.text,
|
||||
@@ -2487,8 +2752,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
|
||||
@@ -2505,6 +2784,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
|
||||
@@ -3085,18 +3422,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);
|
||||
}
|
||||
@@ -3246,8 +3587,6 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _handleDisconnection() {
|
||||
// Disable wake lock when connection is lost
|
||||
WakelockPlus.disable();
|
||||
_stopBatteryPolling();
|
||||
|
||||
for (final entry in _pendingRepeaterAcks.values) {
|
||||
@@ -3271,11 +3610,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>{};
|
||||
|
||||
@@ -3301,7 +3666,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) {
|
||||
@@ -3325,6 +3694,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;
|
||||
@@ -3382,3 +3936,10 @@ class _RepeaterAckContext {
|
||||
required this.messageBytes,
|
||||
});
|
||||
}
|
||||
|
||||
class _PendingCommandAck {
|
||||
final int commandCode;
|
||||
final String? channelSendQueueId;
|
||||
|
||||
_PendingCommandAck({required this.commandCode, this.channelSendQueueId});
|
||||
}
|
||||
|
||||
@@ -13,12 +13,22 @@ class BufferReader {
|
||||
int readByte() => readBytes(1)[0];
|
||||
|
||||
Uint8List readBytes(int count) {
|
||||
if (_pointer + count > _buffer.length) {
|
||||
throw RangeError(
|
||||
'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
|
||||
);
|
||||
}
|
||||
final data = _buffer.sublist(_pointer, _pointer + count);
|
||||
_pointer += count;
|
||||
return data;
|
||||
}
|
||||
|
||||
void skipBytes(int count) {
|
||||
if (_pointer + count > _buffer.length) {
|
||||
throw RangeError(
|
||||
'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
|
||||
);
|
||||
}
|
||||
_pointer += count;
|
||||
}
|
||||
|
||||
@@ -151,6 +161,7 @@ const int cmdGetContactByKey = 30;
|
||||
const int cmdGetChannel = 31;
|
||||
const int cmdSetChannel = 32;
|
||||
const int cmdSendTracePath = 36;
|
||||
const int cmdSetOtherParams = 38;
|
||||
const int cmdGetRadioSettings = 57;
|
||||
const int cmdGetTelemetryReq = 39;
|
||||
const int cmdGetCustomVar = 40;
|
||||
@@ -166,7 +177,7 @@ const int reqTypeGetStatus = 0x01;
|
||||
const int reqTypeKeepAlive = 0x02;
|
||||
const int reqTypeGetTelemetry = 0x03;
|
||||
const int reqTypeGetAccessList = 0x05;
|
||||
const int reqTypeGetNeighbours = 0x06;
|
||||
const int reqTypeGetNeighbors = 0x06;
|
||||
|
||||
// Repeater response codes
|
||||
const int respServerLoginOk = 0;
|
||||
@@ -212,6 +223,30 @@ const int advTypeRepeater = 2;
|
||||
const int advTypeRoom = 3;
|
||||
const int advTypeSensor = 4;
|
||||
|
||||
// Payload Types
|
||||
const int payloadTypeREQ =
|
||||
0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
|
||||
const int payloadTypeRESPONSE =
|
||||
0x01; // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
|
||||
const int payloadTypeTXTMSG =
|
||||
0x02; // a plain text message (prefixed with dest/src hashes, MAC) (enc data: timestamp, text)
|
||||
const int payloadTypeACK = 0x03; // a simple ack
|
||||
const int payloadTypeADVERT = 0x04; // a node advertising its Identity
|
||||
const int payloadTypeGRPTXT =
|
||||
0x05; // an (unverified) group text message (prefixed with channel hash, MAC) (enc data: timestamp, "name: msg")
|
||||
const int payloadTypeGRPDATA =
|
||||
0x06; // an (unverified) group datagram (prefixed with channel hash, MAC) (enc data: timestamp, blob)
|
||||
const int payloadTypeANONREQ =
|
||||
0x07; // generic request (prefixed with dest_hash, ephemeral pub_key, MAC) (enc data: ...)
|
||||
const int payloadTypePATH =
|
||||
0x08; // returned path (prefixed with dest/src hashes, MAC) (enc data: path, extra)
|
||||
const int payloadTypeTRACE = 0x09; // trace a path, collecting SNI for each hop
|
||||
const int payloadTypeMULTIPART = 0x0A; // packet is one of a set of packets
|
||||
const int payloadTypeCONTROL = 0x0B; // a control/discovery packet
|
||||
//...
|
||||
const int payloadTypeRawCustom =
|
||||
0x0F; // custom packet as raw bytes, for applications with custom encryption, payloads, etc
|
||||
|
||||
// Sizes
|
||||
const int pubKeySize = 32;
|
||||
const int maxPathSize = 64;
|
||||
@@ -255,6 +290,7 @@ int _minPositive(int a, int b) {
|
||||
const int contactPubKeyOffset = 1;
|
||||
const int contactTypeOffset = 33;
|
||||
const int contactFlagsOffset = 34;
|
||||
const int contactFlagFavorite = 0x01;
|
||||
const int contactPathLenOffset = 35;
|
||||
const int contactPathOffset = 36;
|
||||
const int contactNameOffset = 100;
|
||||
@@ -550,18 +586,29 @@ Uint8List buildSetChannelFrame(int channelIndex, String name, Uint8List psk) {
|
||||
}
|
||||
|
||||
// Build CMD_SET_RADIO_PARAMS frame
|
||||
// Format: [cmd][freq x4][bw x4][sf][cr]
|
||||
// Format: [cmd][freq x4][bw x4][sf][cr] (pre-v9)
|
||||
// [cmd][freq x4][bw x4][sf][cr][repeat] (firmware v9+)
|
||||
// freq: frequency in Hz (300000-2500000)
|
||||
// bw: bandwidth in Hz (7000-500000)
|
||||
// sf: spreading factor (5-12)
|
||||
// cr: coding rate (5-8)
|
||||
Uint8List buildSetRadioParamsFrame(int freqHz, int bwHz, int sf, int cr) {
|
||||
// clientRepeat: enable off-grid packet repeat (firmware v9+, omit for older)
|
||||
Uint8List buildSetRadioParamsFrame(
|
||||
int freqHz,
|
||||
int bwHz,
|
||||
int sf,
|
||||
int cr, {
|
||||
bool? clientRepeat,
|
||||
}) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSetRadioParams);
|
||||
writer.writeUInt32LE(freqHz);
|
||||
writer.writeUInt32LE(bwHz);
|
||||
writer.writeByte(sf);
|
||||
writer.writeByte(cr);
|
||||
if (clientRepeat != null) {
|
||||
writer.writeByte(clientRepeat ? 1 : 0);
|
||||
}
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
@@ -777,3 +824,22 @@ Uint8List buildZeroHopContact(Uint8List pubKey) {
|
||||
writer.writeBytes(pubKey);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_SET_OTHER_PARAMS frame
|
||||
// Format: [cmd][allowAutoAddContacts][allowTelemetryFlags][advertLocationPolicy][multiAcks]
|
||||
Uint8List buildSetOtherParamsFrame(
|
||||
bool allowAutoAddContacts,
|
||||
int allowTelemetryFlags,
|
||||
int advertLocationPolicy,
|
||||
int multiAcks,
|
||||
) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSetOtherParams);
|
||||
writer.writeByte(
|
||||
allowAutoAddContacts ? 0x00 : 0x01,
|
||||
); // Allow Auto Add Contacts
|
||||
writer.writeByte(allowTelemetryFlags); // Allow Telemetry Flags
|
||||
writer.writeByte(advertLocationPolicy); // Advertisement Location Policy
|
||||
writer.writeByte(multiAcks); // Multi Acknowledgements
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user