Merge branch 'main' into fix/linux-ble-pairing-flow

This commit is contained in:
just_stuff_tm
2026-03-24 02:24:28 -04:00
committed by GitHub
80 changed files with 23673 additions and 1555 deletions
+424 -150
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:crypto/crypto.dart' as crypto;
import 'package:pointycastle/export.dart';
@@ -9,6 +10,7 @@ import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_
import '../models/channel.dart';
import '../models/channel_message.dart';
import '../models/companion_radio_stats.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../models/path_selection.dart';
@@ -145,6 +147,10 @@ class MeshCoreConnector extends ChangeNotifier {
Timer? _selfInfoRetryTimer;
Timer? _reconnectTimer;
Timer? _batteryPollTimer;
Timer? _radioStatsPollTimer;
int _radioStatsPollRefCount = 0;
final ValueNotifier<CompanionRadioStats?> radioStatsNotifier =
ValueNotifier<CompanionRadioStats?>(null);
int _reconnectAttempts = 0;
bool _notifyListenersDirty = false;
static const Duration _notifyListenersDebounce = Duration(milliseconds: 50);
@@ -162,6 +168,10 @@ class MeshCoreConnector extends ChangeNotifier {
int? _currentCr;
bool? _clientRepeat;
int? _firmwareVerCode;
int _pathHashByteWidth = 1;
CompanionRadioStats? _latestRadioStats;
Stopwatch? _airtimeBumpStopwatch;
int _prevTotalAirSecs = 0;
int? _batteryMillivolts;
double? _selfLatitude;
double? _selfLongitude;
@@ -175,9 +185,14 @@ class MeshCoreConnector extends ChangeNotifier {
DateTime _lastRxTime = DateTime.now();
DateTime _lastRadioRxTime = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastContactMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastChannelMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0);
static const int _radioQuietMs = 3000;
static const int _radioQuietMaxWaitMs = 3000;
static const int _contactMsgBackoffMs = 5000;
/// When companion radio stats are unavailable, keep the legacy fixed backoff.
static const int _contactMsgBackoffFallbackMs = 5000;
static const int _contactMsgBackoffMinMs = 500;
static const int _contactMsgBackoffMaxMs = 15000;
bool _batteryRequested = false;
bool _awaitingSelfInfo = false;
bool _hasReceivedDeviceInfo = false;
@@ -259,6 +274,9 @@ class MeshCoreConnector extends ChangeNotifier {
int? _activeChannelIndex;
List<int> _channelOrder = [];
int _storageUsedKb = -1;
int _storageTotalKb = -1;
// Getters
MeshCoreConnectionState get state => _state;
BluetoothDevice? get device => _device;
@@ -324,6 +342,19 @@ class MeshCoreConnector extends ChangeNotifier {
List<DirectRepeater> get directRepeaters => _directRepeaters;
int? get currentTxPower => _currentTxPower;
int? get maxTxPower => _maxTxPower;
int get pathHashByteWidth => _pathHashByteWidth;
CompanionRadioStats? get latestRadioStats => _latestRadioStats;
bool get supportsCompanionRadioStats => (_firmwareVerCode ?? 0) >= 8;
bool get radioStatsAirActivityPulse {
final sw = _airtimeBumpStopwatch;
if (sw == null || !sw.isRunning) return false;
return sw.elapsed < const Duration(seconds: 2);
}
int? get currentFreqHz => _currentFreqHz;
int? get currentBwHz => _currentBwHz;
int? get currentSf => _currentSf;
@@ -333,10 +364,17 @@ class MeshCoreConnector extends ChangeNotifier {
bool? get autoAddRoomServers => _autoAddRoomServers;
bool? get autoAddSensors => _autoAddSensors;
bool? get autoAddOverwriteOldest => _overwriteOldest;
int get telemetryModeBase => _telemetryModeBase;
int get telemetryModeLoc => _telemetryModeLoc;
int get telemetryModeEnv => _telemetryModeEnv;
int get advertLocationPolicy => _advertLocPolicy;
int get multiAcks => _multiAcks;
bool? get clientRepeat => _clientRepeat;
int? get firmwareVerCode => _firmwareVerCode;
Map<String, String>? get currentCustomVars => _currentCustomVars;
int? get batteryMillivolts => _batteryMillivolts;
int? get storageUsedKb => _storageUsedKb;
int? get storageTotalKb => _storageTotalKb;
int get maxContacts => _maxContacts;
int get maxChannels => _maxChannels;
Set<String> get knownContactKeys => Set.unmodifiable(_knownContactKeys);
@@ -773,15 +811,70 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
Future<void> _waitForRadioQuiet() async {
// Wait for backoff after receiving a contact message (avoid collision
// with their transmission still propagating through repeaters)
final msSinceContactMsg = DateTime.now()
.difference(_lastContactMsgRxTime)
.inMilliseconds;
if (msSinceContactMsg < _contactMsgBackoffMs) {
final waitMs = _contactMsgBackoffMs - msSinceContactMsg;
debugPrint('Contact message backoff: waiting ${waitMs}ms');
/// After an incoming DM or channel message, wait before TX so we do not
/// collide with mesh propagation. With companion stats, scale wait by RF
/// conditions (up to [_contactMsgBackoffMaxMs]); otherwise use
/// [_contactMsgBackoffFallbackMs].
int _contactMessageBackoffTargetMs() {
if (!supportsCompanionRadioStats || _latestRadioStats == null) {
return _contactMsgBackoffFallbackMs;
}
final stats = _latestRadioStats!;
final nf = stats.noiseFloorDbm.toDouble();
// Quieter (more negative) → lower score; noisier → higher.
const noiseQuietDbm = -118.0;
const noiseNoisyDbm = -88.0;
final noiseT = ((nf - noiseQuietDbm) / (noiseNoisyDbm - noiseQuietDbm))
.clamp(0.0, 1.0);
final snr = stats.lastSnrDb;
const snrGood = 12.0;
const snrBad = -2.0;
final snrT = (1.0 - ((snr - snrBad) / (snrGood - snrBad))).clamp(0.0, 1.0);
final airBusy = _recentAirtimeBusyFraction();
final severity = (math.max(noiseT, snrT) * 0.82 + airBusy * 0.18).clamp(
0.0,
1.0,
);
return (_contactMsgBackoffMinMs +
severity * (_contactMsgBackoffMaxMs - _contactMsgBackoffMinMs))
.round();
}
/// 1.0 shortly after TX/RX airtime counters increase, decaying to 0 over ~8s.
double _recentAirtimeBusyFraction() {
final sw = _airtimeBumpStopwatch;
if (sw == null || !sw.isRunning) return 0;
final ms = sw.elapsedMilliseconds;
const windowMs = 8000;
if (ms >= windowMs) return 0;
return 1.0 - (ms / windowMs);
}
/// Start of the post-inbound cool-down: the later of BLE message RX time and
/// companion airtime bump ([_airtimeBumpStopwatch], same as the activity dot).
DateTime _postTxBackoffAnchor(DateTime lastInboundRxTime) {
if (!supportsCompanionRadioStats) return lastInboundRxTime;
final sw = _airtimeBumpStopwatch;
if (sw == null || !sw.isRunning) return lastInboundRxTime;
final bumpAt = DateTime.now().subtract(sw.elapsed);
return bumpAt.isAfter(lastInboundRxTime) ? bumpAt : lastInboundRxTime;
}
Future<void> _waitForRadioQuiet({required DateTime lastInboundRxTime}) async {
// Wait for backoff after inbound traffic / RF airtime (avoid collision with
// mesh propagation). Elapsed time uses the dot's airtime bump when newer.
final backoffTargetMs = _contactMessageBackoffTargetMs();
final anchor = _postTxBackoffAnchor(lastInboundRxTime);
final msSinceAnchor = DateTime.now().difference(anchor).inMilliseconds;
if (msSinceAnchor < backoffTargetMs) {
final waitMs = backoffTargetMs - msSinceAnchor;
debugPrint(
'Post-inbound backoff: waiting ${waitMs}ms '
'(target=${backoffTargetMs}ms, anchorAge=${msSinceAnchor}ms)',
);
await Future<void>.delayed(Duration(milliseconds: waitMs));
}
@@ -815,7 +908,7 @@ class MeshCoreConnector extends ChangeNotifier {
) async {
if (!isConnected || text.isEmpty) return;
try {
await _waitForRadioQuiet();
await _waitForRadioQuiet(lastInboundRxTime: _lastContactMsgRxTime);
final outboundText = prepareContactOutboundText(contact, text);
await sendFrame(
buildSendTextMsgFrame(
@@ -1154,6 +1247,7 @@ class MeshCoreConnector extends ChangeNotifier {
);
await _requestDeviceInfo();
_startBatteryPolling();
if (_radioStatsPollRefCount > 0) _startRadioStatsPolling();
var gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
);
@@ -1259,6 +1353,7 @@ class MeshCoreConnector extends ChangeNotifier {
_pendingInitialChannelSync = true;
await _requestDeviceInfo();
_startBatteryPolling();
if (_radioStatsPollRefCount > 0) _startRadioStatsPolling();
var gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
@@ -1893,6 +1988,7 @@ class MeshCoreConnector extends ChangeNotifier {
await _requestDeviceInfo();
_startBatteryPolling();
if (_radioStatsPollRefCount > 0) _startRadioStatsPolling();
final gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
@@ -1920,6 +2016,7 @@ class MeshCoreConnector extends ChangeNotifier {
_pendingInitialContactsSync = false;
_bleInitialSyncStarted = false;
_pendingDeferredChannelSyncAfterContacts = false;
_pathHashByteWidth = 1;
}
bool get _shouldAutoReconnect =>
@@ -1999,6 +2096,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
_setState(MeshCoreConnectionState.disconnecting);
_stopBatteryPolling();
_stopRadioStatsPolling();
await _usbFrameSubscription?.cancel();
_usbFrameSubscription = null;
@@ -2144,6 +2242,49 @@ class MeshCoreConnector extends ChangeNotifier {
_batteryPollTimer = null;
}
void _startRadioStatsPolling() {
_radioStatsPollTimer?.cancel();
_radioStatsPollTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!isConnected) {
_stopRadioStatsPolling();
return;
}
unawaited(requestRadioStats());
});
}
void _stopRadioStatsPolling() {
_radioStatsPollTimer?.cancel();
_radioStatsPollTimer = null;
}
void acquireRadioStatsPolling() {
_radioStatsPollRefCount++;
if (_radioStatsPollRefCount == 1 && isConnected) {
_startRadioStatsPolling();
}
}
void releaseRadioStatsPolling() {
_radioStatsPollRefCount = (_radioStatsPollRefCount - 1).clamp(0, 999);
if (_radioStatsPollRefCount == 0) {
_stopRadioStatsPolling();
}
}
Future<void> requestRadioStats() async {
if (!isConnected) return;
if (!supportsCompanionRadioStats) return;
try {
await sendFrame(buildGetStatsFrame(statsTypeRadio));
} catch (_) {}
}
Future<void> setPathHashMode(int mode) async {
if (!isConnected) return;
await sendFrame(buildSetPathHashModeFrame(mode.clamp(0, 2)));
}
Future<void> refreshDeviceInfo() async {
if (!isConnected) return;
if (PlatformInfo.isWeb &&
@@ -2346,13 +2487,36 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
Future<void> setContactFavorite(Contact contact, bool isFavorite) async {
Future<void> setContactFlags(
Contact contact, {
bool? isFavorite,
bool? teleBase,
bool? teleLoc,
bool? teleEnv,
}) async {
if (!isConnected) return;
final latestContact =
await _fetchContactSnapshotFromDevice(contact.publicKey) ?? contact;
final updatedFlags = isFavorite
? (latestContact.flags | contactFlagFavorite)
: (latestContact.flags & ~contactFlagFavorite);
int updatedFlags = isFavorite != null
? (isFavorite
? (latestContact.flags | contactFlagFavorite)
: (latestContact.flags & ~contactFlagFavorite))
: latestContact.flags;
updatedFlags = teleBase != null
? (teleBase
? (updatedFlags | contactFlagTeleBase)
: (updatedFlags & ~contactFlagTeleBase))
: updatedFlags;
updatedFlags = teleLoc != null
? (teleLoc
? (updatedFlags | contactFlagTeleLoc)
: (updatedFlags & ~contactFlagTeleLoc))
: updatedFlags;
updatedFlags = teleEnv != null
? (teleEnv
? (updatedFlags | contactFlagTeleEnv)
: (updatedFlags & ~contactFlagTeleEnv))
: updatedFlags;
await sendFrame(
buildUpdateContactPathFrame(
@@ -2518,9 +2682,7 @@ class MeshCoreConnector extends ChangeNotifier {
outboundText,
selfKey,
);
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final ackHashHex = ackHashToHex(ackHash);
final messageBytes = utf8.encode(outboundText).length;
_pendingRepeaterAcks[ackHashHex]?.timeout?.cancel();
_pendingRepeaterAcks[ackHashHex] = _RepeaterAckContext(
@@ -2612,6 +2774,7 @@ class MeshCoreConnector extends ChangeNotifier {
// Send the reaction to the device (don't add as a visible message)
final reactionQueueId = _nextReactionSendQueueId();
_pendingChannelSentQueue.add(reactionQueueId);
await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime);
await sendFrame(
buildSendChannelTextMsgFrame(channel.index, text),
channelSendQueueId: reactionQueueId,
@@ -2636,6 +2799,7 @@ class MeshCoreConnector extends ChangeNotifier {
(isChannelSmazEnabled(channel.index) && !isStructuredPayload)
? Smaz.encodeIfSmaller(text)
: text;
await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime);
await sendFrame(
buildSendChannelTextMsgFrame(channel.index, outboundText),
channelSendQueueId: message.messageId,
@@ -2892,6 +3056,31 @@ class MeshCoreConnector extends ChangeNotifier {
await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}');
}
Future<void> setTelemetryModeBase(
int base,
int location,
int env,
int advert,
int multiAcks,
) async {
if (!isConnected) return;
_telemetryModeBase = base.clamp(teleModeDeny, teleModeAllowAll).toInt();
_telemetryModeLoc = location.clamp(teleModeDeny, teleModeAllowAll).toInt();
_telemetryModeEnv = env.clamp(teleModeDeny, teleModeAllowAll).toInt();
_advertLocPolicy = advert.clamp(0, 1).toInt();
_multiAcks = multiAcks.clamp(0, 2).toInt();
await sendFrame(
buildSetOtherParamsFrame(
(_telemetryModeEnv << 4) |
(_telemetryModeLoc << 2) |
_telemetryModeBase,
_advertLocPolicy,
_multiAcks,
),
);
notifyListeners();
}
Future<void> getChannels({int? maxChannels, bool force = false}) async {
if (!isConnected) return;
if (_isSyncingChannels) {
@@ -3074,7 +3263,7 @@ class MeshCoreConnector extends ChangeNotifier {
_bleDebugLogService?.logFrame(frame, outgoing: false);
final code = frame[0];
debugPrint('RX frame: code=$code len=${frame.length}');
// debugPrint('RX frame: code=$code len=${frame.length}');
switch (code) {
case respCodeOk:
@@ -3176,6 +3365,9 @@ class MeshCoreConnector extends ChangeNotifier {
case respCodeBattAndStorage:
_handleBatteryAndStorage(frame);
break;
case respCodeStats:
_handleStatsFrame(frame);
break;
case respCodeCustomVars:
_handleCustomVars(frame);
break;
@@ -3248,8 +3440,8 @@ class MeshCoreConnector extends ChangeNotifier {
final reader = BufferReader(frame);
try {
reader.skipBytes(2);
_currentTxPower = reader.readByte();
_maxTxPower = reader.readByte();
_currentTxPower = reader.readInt8();
_maxTxPower = reader.readInt8();
_selfPublicKey = reader.readBytes(pubKeySize);
_selfLatitude = reader.readInt32LE() / 1000000.0;
_selfLongitude = reader.readInt32LE() / 1000000.0;
@@ -3267,7 +3459,7 @@ class MeshCoreConnector extends ChangeNotifier {
_currentSf = reader.readByte();
_currentCr = reader.readByte();
_selfName = reader.readString();
_selfName = reader.readCString();
} catch (e) {
_appDebugLogService?.error(
'Error parsing SELF_INFO frame: $e',
@@ -3343,6 +3535,13 @@ class MeshCoreConnector extends ChangeNotifier {
if (frame.length >= 81) {
_clientRepeat = frame[80] != 0;
}
// Path hash mode v10+ (byte 81): width = mode + 1 byte(s) per hop
if (frame.length >= 82) {
final mode = (frame[81] & 0xFF).clamp(0, 2);
_pathHashByteWidth = mode + 1;
} else {
_pathHashByteWidth = 1;
}
// Firmware reports MAX_CONTACTS / 2 for v3+ device info.
final reportedContacts = frame[2];
@@ -3402,20 +3601,42 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(_requestNextQueuedMessage());
}
void _handleStatsFrame(Uint8List frame) {
final stats = CompanionRadioStats.tryParse(frame);
if (stats == null) return;
final total = stats.txAirSecs + stats.rxAirSecs;
if (total > _prevTotalAirSecs) {
(_airtimeBumpStopwatch ??= Stopwatch()).reset();
_airtimeBumpStopwatch!.start();
}
_prevTotalAirSecs = total;
_latestRadioStats = stats;
radioStatsNotifier.value = stats;
}
void _handleBatteryAndStorage(Uint8List frame) {
// Frame format from C++:
// [0] = RESP_CODE_BATT_AND_STORAGE
// [1-2] = battery_mv (uint16 LE)
// [3-6] = storage_used_kb (uint32 LE)
// [7-10] = storage_total_kb (uint32 LE)
if (frame.length >= 3) {
_batteryMillivolts = readUint16LE(frame, 1);
try {
final reader = BufferReader(frame);
reader.skipBytes(1);
_batteryMillivolts = reader.readUInt16LE();
_storageUsedKb = reader.readUInt32LE();
_storageTotalKb = reader.readUInt32LE();
final volts = (_batteryMillivolts! / 1000.0).toStringAsFixed(2);
_appDebugLogService?.info(
'Pulled battery: $volts V ($_batteryMillivolts mV)',
tag: 'Battery',
);
notifyListeners();
} catch (e) {
_appDebugLogService?.error(
'Error parsing battery and storage frame: $e',
tag: 'Connector',
);
}
}
@@ -3761,9 +3982,10 @@ class MeshCoreConnector extends ChangeNotifier {
}
bool _pathMatchesContact(Uint8List pathBytes, Uint8List publicKey) {
if (pathBytes.isEmpty || publicKey.length < pathHashSize) return false;
for (int i = 0; i + pathHashSize <= pathBytes.length; i += pathHashSize) {
final prefix = pathBytes.sublist(i, i + pathHashSize);
final w = _pathHashByteWidth;
if (pathBytes.isEmpty || publicKey.length < w) return false;
for (int i = 0; i + w <= pathBytes.length; i += w) {
final prefix = pathBytes.sublist(i, i + w);
if (_matchesPrefix(publicKey, prefix)) {
return true;
}
@@ -3911,7 +4133,7 @@ class MeshCoreConnector extends ChangeNotifier {
reader.skipBytes(4); // Skip extra 4 bytes for signed/plain variants
}
final msgText = reader.readString();
final msgText = reader.readCString();
final flags = txtType;
final shiftedType = flags >> 2;
@@ -4048,6 +4270,7 @@ class MeshCoreConnector extends ChangeNotifier {
if (_shouldDropSelfChannelMessage(parsed.senderName, parsed.pathBytes)) {
return;
}
_lastChannelMsgRxTime = DateTime.now();
final contentHash = _computeContentHash(
parsed.channelIndex!,
parsed.timestamp.millisecondsSinceEpoch ~/ 1000,
@@ -4073,68 +4296,87 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleLogRxData(Uint8List frame) {
if (frame.length < 4) return;
final raw = Uint8List.fromList(frame.sublist(3));
final packet = _parseRawPacket(raw);
if (packet == null || packet.payloadType != _payloadTypeGroupText) return;
try {
final reader = BufferReader(frame);
reader.skipBytes(3); // Skip header
final payload = packet.payload;
if (payload.length <= _cipherMacSize) return;
final channelHash = payload[0];
final encrypted = Uint8List.fromList(payload.sublist(1));
final raw = reader.readRemainingBytes();
final packet = _parseRawPacket(raw);
if (packet == null || packet.payloadType != _payloadTypeGroupText) return;
// Use cached channels as fallback if live channels not yet loaded
final channelsToSearch = _channels.isNotEmpty ? _channels : _cachedChannels;
for (final channel in channelsToSearch) {
if (channel.isEmpty) continue;
final hash = _computeChannelHash(channel.psk);
if (hash != channelHash) continue;
final payload = BufferReader(packet.payload);
final channelHash = payload.readByte();
final encrypted = Uint8List.fromList(payload.readRemainingBytes());
final decrypted = _decryptPayload(channel.psk, encrypted);
if (decrypted == null || decrypted.length < 6) return;
// Use cached channels as fallback if live channels not yet loaded
final channelsToSearch = _channels.isNotEmpty
? _channels
: _cachedChannels;
for (final channel in channelsToSearch) {
if (channel.isEmpty) continue;
final hash = _computeChannelHash(channel.psk);
if (hash != channelHash) continue;
try {
final decryptedBytes = _decryptPayload(channel.psk, encrypted);
if (decryptedBytes == null || decryptedBytes.length < 6) return;
final decrypted = BufferReader(decryptedBytes);
final txtType = decrypted[4];
if ((txtType >> 2) != 0) {
return;
final timestampRaw = decrypted.readUInt32LE();
final txtType = decrypted.readByte();
if ((txtType >> 2) != 0) {
return;
}
final text = decrypted.readCString();
final parsed = _splitSenderText(text);
final decodedText =
Smaz.tryDecodePrefixed(parsed.text) ?? parsed.text;
if (_shouldDropSelfChannelMessage(
parsed.senderName,
packet.pathBytes,
)) {
return;
}
final pktHash = _computePacketHash(
packet.payloadType,
packet.payload,
);
final message = ChannelMessage(
senderKey: null,
senderName: parsed.senderName,
text: decodedText,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
status: ChannelMessageStatus.sent,
pathLength: packet.isFlood ? packet.hopCount : 0,
pathBytes: packet.pathBytes,
channelIndex: channel.index,
packetHash: pktHash,
);
_updateContactLastMessageAtByName(
parsed.senderName,
message.timestamp,
pathBytes: message.pathBytes,
);
final isNew = _addChannelMessage(channel.index, message);
_maybeIncrementChannelUnread(message, isNew: isNew);
notifyListeners();
if (isNew) {
final label = channel.name.isEmpty
? 'Channel ${channel.index}'
: channel.name;
_maybeNotifyChannelMessage(message, channelName: label);
}
return;
} catch (e) {
appLogger.warn('Decryption failed for channel ${channel.index}: $e');
}
}
final timestampRaw = readUint32LE(decrypted, 0);
final text = readCString(decrypted, 5, decrypted.length - 5);
final parsed = _splitSenderText(text);
final decodedText = Smaz.tryDecodePrefixed(parsed.text) ?? parsed.text;
if (_shouldDropSelfChannelMessage(parsed.senderName, packet.pathBytes)) {
return;
}
final pktHash = _computePacketHash(packet.payloadType, packet.payload);
final message = ChannelMessage(
senderKey: null,
senderName: parsed.senderName,
text: decodedText,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
status: ChannelMessageStatus.sent,
pathLength: packet.isFlood ? packet.hopCount : 0,
pathBytes: packet.pathBytes,
channelIndex: channel.index,
packetHash: pktHash,
);
_updateContactLastMessageAtByName(
parsed.senderName,
message.timestamp,
pathBytes: message.pathBytes,
);
final isNew = _addChannelMessage(channel.index, message);
_maybeIncrementChannelUnread(message, isNew: isNew);
notifyListeners();
if (isNew) {
final label = channel.name.isEmpty
? 'Channel ${channel.index}'
: channel.name;
_maybeNotifyChannelMessage(message, channelName: label);
}
return;
} catch (e) {
appLogger.warn('Error handling log RX data frame: $e');
}
}
@@ -4145,15 +4387,15 @@ class MeshCoreConnector extends ChangeNotifier {
// [2-5] = expected_ack_hash (uint32)
// [6-9] = estimated_timeout_ms (uint32)
if (frame.length >= 10) {
final ackHash = Uint8List.fromList(frame.sublist(2, 6));
final timeoutMs = readUint32LE(frame, 6);
try {
final reader = BufferReader(frame);
reader.skipBytes(2); //Skip code and is_flood
final ackHash = reader.readUInt32LE();
final timeoutMs = reader.readUInt32LE();
// Check if this is a CLI command ACK - if so, ignore it
if (_lastSentWasCliCommand) {
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final ackHashHex = ackHashToHex(ackHash);
debugPrint('Ignoring CLI command ACK (sent): $ackHashHex');
_lastSentWasCliCommand = false;
return;
@@ -4172,7 +4414,8 @@ class MeshCoreConnector extends ChangeNotifier {
if (_markNextPendingChannelMessageSent()) {
return;
}
} else {
} catch (e) {
appLogger.warn('Error handling message sent frame: $e');
// Fallback to old behavior
for (var messages in _conversations.values) {
for (int i = messages.length - 1; i >= 0; i--) {
@@ -4251,9 +4494,11 @@ class MeshCoreConnector extends ChangeNotifier {
// [1-4] = ack_hash (uint32)
// [5-8] = trip_time_ms (uint32)
if (frame.length >= 9) {
final ackHash = Uint8List.fromList(frame.sublist(1, 5));
final tripTimeMs = readUint32LE(frame, 5);
try {
final reader = BufferReader(frame);
reader.skipBytes(1); // Skip code
final ackHash = reader.readUInt32LE();
final tripTimeMs = reader.readUInt32LE();
// CLI command ACKs are already filtered in _handleMessageSent, so this should only see real messages
@@ -4265,7 +4510,8 @@ class MeshCoreConnector extends ChangeNotifier {
if (_retryService != null) {
_retryService!.handleAckReceived(ackHash, tripTimeMs);
}
} else {
} catch (e) {
appLogger.warn('Error handling send confirmed frame: $e');
// Fallback to old behavior
for (var messages in _conversations.values) {
for (int i = messages.length - 1; i >= 0; i--) {
@@ -4280,10 +4526,8 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
bool _handleRepeaterCommandSent(Uint8List ackHash, int timeoutMs) {
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
bool _handleRepeaterCommandSent(int ackHash, int timeoutMs) {
final ackHashHex = ackHashToHex(ackHash);
final entry = _pendingRepeaterAcks[ackHashHex];
if (entry == null) return false;
@@ -4301,10 +4545,8 @@ class MeshCoreConnector extends ChangeNotifier {
return true;
}
bool _handleRepeaterCommandAck(Uint8List ackHash, int tripTimeMs) {
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
bool _handleRepeaterCommandAck(int ackHash, int tripTimeMs) {
final ackHashHex = ackHashToHex(ackHash);
final entry = _pendingRepeaterAcks.remove(ackHashHex);
if (entry == null) return false;
entry.timeout?.cancel();
@@ -4655,36 +4897,35 @@ class MeshCoreConnector extends ChangeNotifier {
}
_RawPacket? _parseRawPacket(Uint8List raw) {
if (raw.length < 3) return null;
var index = 0;
final header = raw[index++];
final routeType = header & _phRouteMask;
final hasTransport =
routeType == _routeTransportFlood || routeType == _routeTransportDirect;
if (hasTransport) {
if (raw.length < index + 4) return null;
index += 4;
}
if (raw.length <= index) return null;
final pathLenRaw = raw[index++];
final pathByteLen = _decodePathByteLen(pathLenRaw);
if (raw.length < index + pathByteLen) return null;
final pathBytes = Uint8List.fromList(
raw.sublist(index, index + pathByteLen),
);
index += pathByteLen;
if (raw.length <= index) return null;
final payload = Uint8List.fromList(raw.sublist(index));
try {
final reader = BufferReader(raw);
final header = reader.readByte();
final routeType = header & _phRouteMask;
final hasTransport =
routeType == _routeTransportFlood ||
routeType == _routeTransportDirect;
if (hasTransport) {
// Skip reserved bytes in transport header made up of two u16 fields
reader.skipBytes(4);
}
final pathLenRaw = reader.readByte();
final pathByteLen = _decodePathByteLen(pathLenRaw);
final pathBytes = reader.readBytes(pathByteLen);
final payload = reader.readBytes(reader.remaining);
return _RawPacket(
header: header,
routeType: routeType,
payloadType: (header >> _phTypeShift) & _phTypeMask,
payloadVer: (header >> _phVerShift) & _phVerMask,
pathLenRaw: pathLenRaw,
pathBytes: pathBytes,
payload: payload,
);
return _RawPacket(
header: header,
routeType: routeType,
payloadType: (header >> _phTypeShift) & _phTypeMask,
payloadVer: (header >> _phVerShift) & _phVerMask,
pathLenRaw: pathLenRaw,
pathBytes: pathBytes,
payload: payload,
);
} catch (e) {
appLogger.warn('Error parsing raw packet: $e');
return null;
}
}
int _computeChannelHash(Uint8List psk) {
@@ -5021,6 +5262,12 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleDisconnection() {
_stopBatteryPolling();
_stopRadioStatsPolling();
_latestRadioStats = null;
radioStatsNotifier.value = null;
_prevTotalAirSecs = 0;
_airtimeBumpStopwatch?.stop();
_airtimeBumpStopwatch = null;
for (final entry in _pendingRepeaterAcks.values) {
entry.timeout?.cancel();
@@ -5103,7 +5350,7 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleCustomVars(Uint8List frame) {
final buf = BufferReader(frame.sublist(1));
try {
_currentCustomVars = _parseKeyValueString(buf.readString());
_currentCustomVars = _parseKeyValueString(buf.readCString());
} catch (e) {
appLogger.warn('Malformed custom vars frame: $e', tag: 'Connector');
}
@@ -5159,6 +5406,8 @@ class MeshCoreConnector extends ChangeNotifier {
_notifyListenersTimer?.cancel();
_reconnectTimer?.cancel();
_batteryPollTimer?.cancel();
_radioStatsPollTimer?.cancel();
radioStatsNotifier.dispose();
_receivedFramesController.close();
_usbManager.dispose();
_tcpConnector.dispose();
@@ -5260,7 +5509,7 @@ class MeshCoreConnector extends ChangeNotifier {
longitude = packet.readInt32LE() / 1e6;
}
if (hasName && packet.remaining > 0) {
name = packet.readString();
name = packet.readCString();
}
} catch (e) {
appLogger.warn('Malformed advert frame: $e', tag: 'Connector');
@@ -5284,6 +5533,17 @@ class MeshCoreConnector extends ChangeNotifier {
);
}
bool hasValidLocation(double? latitude, double? longitude) {
const double epsilon = 1e-6;
final lat = latitude ?? 0.0;
final lon = longitude ?? 0.0;
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
lat >= -90.0 &&
lat <= 90.0 &&
lon >= -180.0 &&
lon <= 180.0;
}
void _handlePayloadAdvertReceived(
Uint8List rawPacket,
Uint8List payload,
@@ -5321,8 +5581,11 @@ class MeshCoreConnector extends ChangeNotifier {
latitude = advert.readInt32LE() / 1e6;
longitude = advert.readInt32LE() / 1e6;
}
// Validate location values if present
hasLocation = hasValidLocation(latitude, longitude);
if (hasName && advert.remaining > 0) {
name = advert.readString();
name = advert.readCString();
}
} catch (e) {
appLogger.warn('Malformed advert frame: $e', tag: 'Connector');
@@ -5386,20 +5649,8 @@ class MeshCoreConnector extends ChangeNotifier {
// CRITICAL: Preserve user's path override when contact is refreshed from device
_contacts[existingIndex] = existing.copyWith(
latitude:
hasLocation &&
latitude != null &&
latitude.abs() <= 90 &&
(latitude != 0 || longitude != 0)
? latitude
: existing.latitude,
longitude:
hasLocation &&
longitude != null &&
longitude.abs() <= 180 &&
(latitude != 0 || longitude != 0)
? longitude
: existing.longitude,
latitude: hasLocation ? latitude : existing.latitude,
longitude: hasLocation ? longitude : existing.longitude,
name: hasName ? name : existing.name,
path: Uint8List.fromList(path.reversed.toList()),
pathLength: path.length,
@@ -5548,6 +5799,29 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(_persistDiscoveredContacts());
notifyListeners();
}
void clearMessagesForContact(Contact contact) {
final contactKeyHex = contact.publicKeyHex;
final messages = _conversations[contactKeyHex];
if (messages == null) return;
messages.clear();
unawaited(_messageStore.saveMessages(contactKeyHex, messages));
markContactRead(contactKeyHex);
notifyListeners();
}
void clearMessagesForChannel(int channelIndex) {
final messages = _channelMessages[channelIndex];
if (messages == null) return;
messages.clear();
unawaited(_channelMessageStore.saveChannelMessages(channelIndex, messages));
markChannelRead(channelIndex);
notifyListeners();
}
void deleteAllPaths() {
_pathHistoryService?.clearAllHistories();
}
}
const int _phRouteMask = 0x03;
+82 -64
View File
@@ -1,6 +1,8 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
// Buffer Reader - sequential binary data reader with pointer tracking
class BufferReader {
int _pointer = 0;
@@ -37,16 +39,6 @@ class BufferReader {
Uint8List readRemainingBytes() => readBytes(remaining);
String readString() {
_lastPointer = _pointer;
final value = readRemainingBytes();
try {
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
} catch (e) {
return String.fromCharCodes(value); // Latin-1 fallback
}
}
String readCStringGreedy(int maxLength) {
_lastPointer = _pointer;
final value = <int>[];
@@ -62,11 +54,12 @@ class BufferReader {
}
}
String readCString(int maxLength) {
String readCString({int maxLength = -1}) {
final backupPointer = _pointer;
final value = <int>[];
int counter = 0;
while (counter < maxLength) {
final maxLen = maxLength >= 0 ? maxLength : remaining;
while (counter < maxLen) {
final byte = readByte();
if (byte == 0) break;
value.add(byte);
@@ -210,16 +203,19 @@ const int cmdSetChannel = 32;
const int cmdSendTracePath = 36;
const int cmdSetOtherParams = 38;
const int cmdSendAnonReq = 57;
const int cmdGetTelemetryReq = 39;
const int cmdSendTelemetryReq = 39;
const int cmdGetCustomVar = 40;
const int cmdSetCustomVar = 41;
const int cmdSendBinaryReq = 50;
const int cmdSetAutoAddConfig = 58;
const int cmdGetAutoAddConfig = 59;
const int cmdSetPathHashMode = 61;
const int cmdGetStats = 56;
// Text message types
const int txtTypePlain = 0;
const int txtTypeCliData = 1;
const int txtTypeSigned = 2;
// Repeater request types (for server requests)
const int reqTypeGetStatus = 0x01;
@@ -251,6 +247,11 @@ const int respCodeChannelMsgRecvV3 = 17;
const int respCodeChannelInfo = 18;
const int respCodeCustomVars = 21;
const int respCodeAutoAddConfig = 25;
const int respCodeStats = 24;
const int statsTypeCore = 0;
const int statsTypeRadio = 1;
const int statsTypePackets = 2;
// Push codes (async from device)
const int pushCodeAdvert = 0x80;
@@ -272,6 +273,10 @@ const int advTypeRepeater = 2;
const int advTypeRoom = 3;
const int advTypeSensor = 4;
const int teleModeDeny = 0;
const int teleModeAllowFlags = 1; // use contact.flags
const int teleModeAllowAll = 2;
// Payload Types
const int payloadTypeREQ =
0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
@@ -310,6 +315,7 @@ const int autoAddSensorFlag =
// Sizes
const int pubKeySize = 32;
const int signatureSize = 64;
const int maxPathSize = 64;
const int pathHashSize = 1;
const int maxNameSize = 32;
@@ -352,6 +358,9 @@ const int contactPubKeyOffset = 1;
const int contactTypeOffset = 33;
const int contactFlagsOffset = 34;
const int contactFlagFavorite = 0x01;
const int contactFlagTeleBase = 0x02; // 'base' permission includes battery
const int contactFlagTeleLoc = 0x04;
const int contactFlagTeleEnv = 0x08; //access environment sensors
const int contactPathLenOffset = 35;
const int contactPathOffset = 36;
const int contactNameOffset = 100;
@@ -370,52 +379,44 @@ const int msgTextOffset = 38;
class ParsedContactText {
final Uint8List senderPrefix;
final String text;
const ParsedContactText({required this.senderPrefix, required this.text});
}
ParsedContactText? parseContactMessageText(Uint8List frame) {
if (frame.isEmpty) return null;
final code = frame[0];
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
final message = BufferReader(frame);
try {
final code = message.readByte();
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
return null;
}
// Companion radio layout:
// [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
if (code == respCodeContactMsgRecvV3) {
// Skip SNR and reserved bytes in v3 layout
message.skipBytes(3);
}
final senderPrefix = message.readBytes(6); // public key
message.skipBytes(1); // path length
final textType = message.readByte();
message.skipBytes(4); // timestamp (4 bytes)
final shiftedType = textType >> 2;
final isSigned = shiftedType == txtTypeSigned || textType == txtTypeSigned;
if (isSigned) {
// Signed messages have a 4-byte signature after the timestamp, before the text
message.skipBytes(4);
}
final text = message.readCString();
if (text.isEmpty) return null;
return ParsedContactText(senderPrefix: senderPrefix, text: text);
} catch (e) {
debugPrint('Error parsing contact message text: $e');
return null;
}
// Companion radio layout:
// [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
final isV3 = code == respCodeContactMsgRecvV3;
final prefixOffset = isV3 ? 4 : 1;
const prefixLen = 6;
final txtTypeOffset = prefixOffset + prefixLen + 1;
final timestampOffset = txtTypeOffset + 1;
final baseTextOffset = timestampOffset + 4;
if (frame.length <= baseTextOffset) return null;
final flags = frame[txtTypeOffset];
final shiftedType = flags >> 2;
final rawType = flags;
final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain;
final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData;
if (!isPlain && !isCli) {
return null;
}
var text = readCString(
frame,
baseTextOffset,
frame.length - baseTextOffset,
).trim();
if (text.isEmpty && frame.length > baseTextOffset + 4) {
text = readCString(
frame,
baseTextOffset + 4,
frame.length - (baseTextOffset + 4),
).trim();
}
if (text.isEmpty) return null;
final senderPrefix = frame.sublist(prefixOffset, prefixOffset + prefixLen);
return ParsedContactText(senderPrefix: senderPrefix, text: text);
}
// Helper to read uint32 little-endian
@@ -438,18 +439,9 @@ int readInt32LE(Uint8List data, int offset) {
return val;
}
// Helper to read null-terminated UTF-8 string
String readCString(Uint8List data, int offset, int maxLen) {
int end = offset;
while (end < offset + maxLen && end < data.length && data[end] != 0) {
end++;
}
try {
return utf8.decode(data.sublist(offset, end), allowMalformed: true);
} catch (e) {
// Fallback to Latin-1 if UTF-8 decoding fails
return String.fromCharCodes(data.sublist(offset, end));
}
// Helper to convert uint32 to hex string
String ackHashToHex(int ackHash) {
return ackHash.toRadixString(16).padLeft(8, '0');
}
// Helper to convert public key to hex string
@@ -569,6 +561,17 @@ Uint8List buildGetBattAndStorageFrame() {
return Uint8List.fromList([cmdGetBattAndStorage]);
}
/// Companion radio stats: [56][statsType] where statsType is statsTypeCore/Radio/Packets.
Uint8List buildGetStatsFrame(int statsType) {
return Uint8List.fromList([cmdGetStats, statsType & 0xFF]);
}
/// Path hash width on air: [61][0][mode], mode 0..2 (mode+1) bytes per hop hash.
Uint8List buildSetPathHashModeFrame(int mode) {
final m = mode.clamp(0, 2);
return Uint8List.fromList([cmdSetPathHashMode, 0, m]);
}
// Build CMD_SET_DEVICE_TIME frame
Uint8List buildSetDeviceTimeFrame(int timestamp) {
final writer = BufferWriter();
@@ -937,3 +940,18 @@ Uint8List buildSetAutoAddConfigFrame({
writer.writeByte(flags);
return writer.toBytes();
}
//Build CMD_SEND_TELEMETRY_REQ
// Format: [cmd][reserved x3][pub_key? x32]
Uint8List buildSendTelemetryReq(Uint8List? pubKey) {
final writer = BufferWriter();
writer.writeByte(cmdSendTelemetryReq);
if (pubKey != null && pubKey.length == pubKeySize) {
writer.writeBytes(Uint8List(3)); // reserved bytes
writer.writeBytes(pubKey);
} else {
writer.writeBytes(Uint8List(4)); // reserved bytes
}
return writer.toBytes();
}