feat: Enhance MeshCoreConnector with storage metrics and improve error handling

- Added storageUsedKb and storageTotalKb properties to MeshCoreConnector.
- Updated battery and storage frame parsing with improved error handling.
- Refactored log RX data handling to use BufferReader for better readability and error management.
- Enhanced message parsing in ChannelMessage and Message classes to utilize BufferReader.
- Introduced new text type for signed messages in meshcore_protocol.dart.
- Updated BLE debug log screen to use BufferReader for payload parsing.
- Refactored message retry service to handle ACK hashes as integers instead of Uint8List.
- Improved message storage serialization and deserialization to accommodate new expectedAckHash type.
- Added wasPulled property to Contact model for better state management.
This commit is contained in:
Winston Lowe 2026-03-21 13:01:02 -07:00
parent 1392c2d00f
commit dbefb0b5f4
11 changed files with 382 additions and 340 deletions

View file

@ -257,6 +257,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;
@ -338,6 +341,8 @@ class MeshCoreConnector extends ChangeNotifier {
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);
@ -3037,14 +3042,23 @@ class MeshCoreConnector extends ChangeNotifier {
// [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.readInt16LE();
_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',
);
}
}
@ -3702,68 +3716,89 @@ 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 = packet.payload;
if (payload.length <= _cipherMacSize) return;
final channelHash = payload[0];
final encrypted = Uint8List.fromList(payload.sublist(1));
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);
// Skip header + SNR + reserved (2)
decrypted.skipBytes(4);
final txtType = decrypted.readByte();
if ((txtType >> 2) != 0) {
return;
}
final txtType = decrypted[4];
if ((txtType >> 2) != 0) {
return;
final timestampRaw = decrypted.readUInt32LE();
final text = decrypted.readString();
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');
}
}
@ -3774,15 +3809,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); // code + 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 = ackHash.toRadixString(16).padLeft(8, '0');
debugPrint('Ignoring CLI command ACK (sent): $ackHashHex');
_lastSentWasCliCommand = false;
return;
@ -3801,7 +3836,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--) {
@ -3880,9 +3916,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
@ -3894,7 +3932,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--) {
@ -3909,10 +3948,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 = ackHash.toRadixString(16).padLeft(8, '0');
final entry = _pendingRepeaterAcks[ackHashHex];
if (entry == null) return false;
@ -3930,10 +3967,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 = ackHash.toRadixString(16).padLeft(8, '0');
final entry = _pendingRepeaterAcks.remove(ackHashHex);
if (entry == null) return false;
entry.timeout?.cancel();

View file

@ -220,6 +220,7 @@ const int cmdGetAutoAddConfig = 59;
// 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;
@ -314,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;
@ -377,88 +379,79 @@ 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) {
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
int readUint32LE(Uint8List data, int offset) {
return data[offset] |
(data[offset + 1] << 8) |
(data[offset + 2] << 16) |
(data[offset + 3] << 24);
}
// Helper to read uint16 little-endian
int readUint16LE(Uint8List data, int offset) {
return data[offset] | (data[offset + 1] << 8);
}
// Helper to read int32 little-endian
int readInt32LE(Uint8List data, int offset) {
int val = readUint32LE(data, offset);
if (val >= 0x80000000) val -= 0x100000000;
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++;
}
final message = BufferReader(frame);
try {
return utf8.decode(data.sublist(offset, end), allowMalformed: true);
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.readString();
if (text.isEmpty) return null;
return ParsedContactText(senderPrefix: senderPrefix, text: text);
} catch (e) {
// Fallback to Latin-1 if UTF-8 decoding fails
return String.fromCharCodes(data.sublist(offset, end));
return null;
}
}
// // Helper to read uint32 little-endian
// int readUint32LE(Uint8List data, int offset) {
// return data[offset] |
// (data[offset + 1] << 8) |
// (data[offset + 2] << 16) |
// (data[offset + 3] << 24);
// }
// // Helper to read uint16 little-endian
// int readUint16LE(Uint8List data, int offset) {
// return data[offset] | (data[offset + 1] << 8);
// }
// // Helper to read int32 little-endian
// int readInt32LE(Uint8List data, int offset) {
// int val = readUint32LE(data, offset);
// if (val >= 0x80000000) val -= 0x100000000;
// 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 public key to hex string
String pubKeyToHex(Uint8List pubKey) {
return pubKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join();

View file

@ -24,20 +24,23 @@ class Channel {
bool get isPublicChannel => pskHex == publicChannelPsk;
static Channel? fromFrame(Uint8List data) {
static Channel? fromFrame(Uint8List frame) {
// CHANNEL_INFO format:
// [0] = RESP_CODE_CHANNEL_INFO (18)
// [1] = channel_idx
// [2-33] = name (32 bytes, null-terminated)
// [34-49] = psk (16 bytes)
if (data.length < 50) return null;
if (data[0] != respCodeChannelInfo) return null;
final index = data[1];
final name = readCString(data, 2, 32);
final psk = Uint8List.fromList(data.sublist(34, 50));
return Channel(index: index, name: name, psk: psk);
if (frame.length < 50) return null;
final reader = BufferReader(frame);
try {
if (reader.readByte() != respCodeChannelInfo) return null;
final index = reader.readByte();
final name = reader.readCStringGreedy(32);
final psk = reader.readBytes(16);
return Channel(index: index, name: name, psk: psk);
} catch (e) {
return null;
}
}
static Channel empty(int index) {

View file

@ -109,89 +109,85 @@ class ChannelMessage {
);
}
static ChannelMessage? fromFrame(Uint8List data) {
static ChannelMessage? fromFrame(Uint8List frame) {
// CHANNEL_MSG_RECV format varies by version:
// V3: [0]=code [1]=SNR [2]=rsv1 [3]=rsv2 [4]=channel_idx [5]=path_len [path... optional] [txt_type] [timestamp x4] [text...]
// Non-V3: [0]=code [1]=channel_idx [2]=path_len [3]=txt_type [4-7]=timestamp [8+]=text
if (data.length < 8) return null;
if (frame.length < 8) return null;
final reader = BufferReader(frame);
try {
final code = reader.readByte();
if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) {
return null;
}
final code = data[0];
if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) {
int pathLen;
int txtType;
Uint8List pathBytes = Uint8List(0);
int channelIdx;
if (code == respCodeChannelMsgRecvV3) {
reader.skipBytes(1); // Skip SNR
final flags = reader.readByte();
reader.skipBytes(1); // Skip reserved byte
channelIdx = reader.readByte();
pathLen = reader.readByte();
txtType = reader.readByte();
final hasPath = (flags & 0x01) != 0;
if (hasPath) {
reader.rewind(); // Rewind to read path length again for pathBytes
pathBytes = reader.readBytes(pathLen);
// Force text type to plain if path is present
txtType = txtTypePlain;
} else {
pathLen = 0;
}
} else {
channelIdx = reader.readByte();
pathLen = reader.readByte();
txtType = reader.readByte();
}
final timestampRaw = reader.readUInt32LE();
if (txtType != txtTypePlain) {
return null;
}
final text = reader.readString();
// Extract sender name and actual message from "name: msg" format
String senderName = 'Unknown';
String actualText = text;
final colonIndex = text.indexOf(':');
if (colonIndex > 0 && colonIndex < text.length - 1 && colonIndex < 50) {
final potentialSender = text.substring(0, colonIndex);
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
senderName = potentialSender;
final offset =
(colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
? colonIndex + 2
: colonIndex + 1;
actualText = text.substring(offset);
}
}
final decodedText = Smaz.tryDecodePrefixed(actualText) ?? actualText;
return ChannelMessage(
senderKey: null,
senderName: senderName,
text: decodedText,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
status: ChannelMessageStatus.sent,
pathLength: pathLen,
pathBytes: pathBytes,
channelIndex: channelIdx,
);
} catch (e) {
// If parsing fails, return null to avoid crashes
return null;
}
int timestampOffset, textOffset, pathLenOffset, txtTypeOffset;
Uint8List pathBytes = Uint8List(0);
int channelIdx;
if (code == respCodeChannelMsgRecvV3) {
channelIdx = data[4];
pathLenOffset = 5;
final pathLen = data[pathLenOffset].toSigned(8);
var cursor = 6;
final hasPathBytesFlag = (data[2] & 0x01) != 0;
final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5;
final hasValidTxtType =
cursor < data.length &&
(data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) &&
canFitPath) {
pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen));
cursor += pathLen;
}
txtTypeOffset = cursor;
cursor += 1; // txt_type
timestampOffset = cursor;
textOffset = cursor + 4;
} else {
channelIdx = data[1];
pathLenOffset = 2;
txtTypeOffset = 3;
timestampOffset = 4;
textOffset = 8;
}
if (data.length < textOffset + 1) return null;
final txtType = data[txtTypeOffset];
if (txtType != txtTypePlain) {
return null;
}
final pathLen = data[pathLenOffset].toSigned(8);
final timestampRaw = readUint32LE(data, timestampOffset);
final text = readCString(data, textOffset, data.length - textOffset);
// Extract sender name and actual message from "name: msg" format
String senderName = 'Unknown';
String actualText = text;
final colonIndex = text.indexOf(':');
if (colonIndex > 0 && colonIndex < text.length - 1 && colonIndex < 50) {
final potentialSender = text.substring(0, colonIndex);
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
senderName = potentialSender;
final offset =
(colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
? colonIndex + 2
: colonIndex + 1;
actualText = text.substring(offset);
}
}
final decodedText = Smaz.tryDecodePrefixed(actualText) ?? actualText;
return ChannelMessage(
senderKey: null,
senderName: senderName,
text: decodedText,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
status: ChannelMessageStatus.sent,
pathLength: pathLen,
pathBytes: pathBytes,
channelIndex: channelIdx,
);
}
static ChannelMessage outgoing(

View file

@ -18,6 +18,7 @@ class Contact {
final DateTime lastSeen;
final DateTime lastMessageAt;
final bool isActive;
final bool wasPulled;
final Uint8List? rawPacket;
Contact({
@ -34,6 +35,7 @@ class Contact {
required this.lastSeen,
DateTime? lastMessageAt,
this.isActive = true,
this.wasPulled = false,
this.rawPacket,
}) : lastMessageAt = lastMessageAt ?? lastSeen;

View file

@ -16,7 +16,7 @@ class Message {
final String? messageId;
final int retryCount;
final int? estimatedTimeoutMs;
final Uint8List? expectedAckHash;
final int? expectedAckHash;
final DateTime? sentAt;
final DateTime? deliveredAt;
final int? tripTimeMs;
@ -56,7 +56,7 @@ class Message {
MessageStatus? status,
int? retryCount,
int? estimatedTimeoutMs,
Uint8List? expectedAckHash,
int? expectedAckHash,
DateTime? sentAt,
DateTime? deliveredAt,
int? tripTimeMs,
@ -90,33 +90,35 @@ class Message {
);
}
static Message? fromFrame(Uint8List data, Uint8List selfPubKey) {
if (data.length < msgTextOffset + 1) return null;
static Message? fromFrame(Uint8List frame, Uint8List selfPubKey) {
if (frame.length < msgTextOffset + 1) return null;
final reader = BufferReader(frame);
try {
final code = reader.readByte();
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
return null;
}
final code = data[0];
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
final senderKey = reader.readBytes(pubKeySize);
final timestampRaw = reader.readInt32LE();
final flags = reader.readByte();
if ((flags >> 2) != txtTypePlain) {
return null;
}
final text = reader.readString();
return Message(
senderKey: senderKey,
text: text,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
isCli: false,
status: MessageStatus.delivered,
pathBytes: Uint8List(0),
);
} catch (e) {
return null;
}
final senderKey = Uint8List.fromList(
data.sublist(msgPubKeyOffset, msgPubKeyOffset + pubKeySize),
);
final timestampRaw = readUint32LE(data, msgTimestampOffset);
final flags = data[msgFlagsOffset];
if ((flags >> 2) != txtTypePlain) {
return null;
}
final text = readCString(data, msgTextOffset, data.length - msgTextOffset);
return Message(
senderKey: senderKey,
text: text,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
isCli: false,
status: MessageStatus.delivered,
pathBytes: Uint8List(0),
);
}
static Message outgoing(

View file

@ -283,66 +283,66 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
if (payload.length < 101) {
return 'ADVERT (short)';
}
var offset = 0;
final pubKey = _bytesToHex(
payload.sublist(offset, offset + 32),
spaced: false,
);
offset += 32;
final timestamp = readUint32LE(payload, offset);
offset += 4;
offset += 64; // signature
final flags = payload[offset++];
final role = _deviceRoleLabel(flags & 0x0F);
final hasLocation = (flags & 0x10) != 0;
final hasFeature1 = (flags & 0x20) != 0;
final hasFeature2 = (flags & 0x40) != 0;
final hasName = (flags & 0x80) != 0;
String? name;
double? lat;
double? lon;
if (hasLocation && payload.length >= offset + 8) {
lat = readInt32LE(payload, offset) / 1000000.0;
lon = readInt32LE(payload, offset + 4) / 1000000.0;
offset += 8;
final reader = BufferReader(payload);
try {
final pubKey = _bytesToHex(reader.readBytes(pubKeySize), spaced: false);
final timestamp = reader.readUInt32LE();
reader.skipBytes(signatureSize);
final flags = reader.readByte();
final role = _deviceRoleLabel(flags & 0x0F);
final hasLocation = (flags & 0x10) != 0;
final hasFeature1 = (flags & 0x20) != 0;
final hasFeature2 = (flags & 0x40) != 0;
final hasName = (flags & 0x80) != 0;
String? name;
double? lat;
double? lon;
if (hasLocation) {
lat = reader.readInt32LE() / 1000000.0;
lon = reader.readInt32LE() / 1000000.0;
}
if (hasFeature1) reader.skipBytes(2);
if (hasFeature2) reader.skipBytes(2);
if (hasName) {
name = reader.readCStringGreedy(maxNameSize);
}
final namePart = (name != null && name.isNotEmpty) ? ' name="$name"' : '';
final locPart = (lat != null && lon != null)
? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}'
: '';
return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}';
} catch (e) {
return 'ADVERT (invalid)';
}
if (hasFeature1) offset += 2;
if (hasFeature2) offset += 2;
if (hasName && payload.length > offset) {
final rawName = String.fromCharCodes(payload.sublist(offset));
final nul = rawName.indexOf('\u0000');
name = nul >= 0 ? rawName.substring(0, nul) : rawName;
name = name.trim();
}
final namePart = (name != null && name.isNotEmpty) ? ' name="$name"' : '';
final locPart = (lat != null && lon != null)
? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}'
: '';
return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}';
}
String _decodeControlSummary(Uint8List payload) {
if (payload.isEmpty) return 'CONTROL (empty)';
final flags = payload[0];
final subType = flags & 0xF0;
if (subType == 0x80) {
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
final typeFilter = payload[1];
final tag = readUint32LE(payload, 2);
final since = payload.length >= 10 ? readUint32LE(payload, 6) : 0;
return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since';
final reader = BufferReader(payload);
try {
final flags = reader.readByte();
final subType = flags & 0xF0;
if (subType == 0x80) {
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
final typeFilter = reader.readByte();
final tag = reader.readInt32LE();
final since = payload.length >= 10 ? reader.readInt32LE() : 0;
return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since';
}
if (subType == 0x90) {
if (payload.length < 14) return 'CONTROL DISCOVER_RESP (short)';
final nodeType = flags & 0x0F;
final snrRaw = payload[1];
final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
final snr = snrSigned / 4.0;
final tag = reader.readInt32LE();
final keyLen = payload.length - 6;
return 'CONTROL DISCOVER_RESP node=${_deviceRoleLabel(nodeType)} snr=${snr.toStringAsFixed(2)} tag=$tag key=$keyLen';
}
return 'CONTROL subtype=0x${subType.toRadixString(16).padLeft(2, '0')}';
} catch (e) {
return 'CONTROL (invalid)';
}
if (subType == 0x90) {
if (payload.length < 14) return 'CONTROL DISCOVER_RESP (short)';
final nodeType = flags & 0x0F;
final snrRaw = payload[1];
final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
final snr = snrSigned / 4.0;
final tag = readUint32LE(payload, 2);
final keyLen = payload.length - 6;
return 'CONTROL DISCOVER_RESP node=${_deviceRoleLabel(nodeType)} snr=${snr.toStringAsFixed(2)} tag=$tag key=$keyLen';
}
return 'CONTROL subtype=0x${subType.toRadixString(16).padLeft(2, '0')}';
}
String _payloadTypeLabel(int payloadType) {

View file

@ -245,6 +245,19 @@ class BleDebugLogService extends ChangeNotifier {
}
}
// Helper to read uint32 little-endian
int readUint32LE(Uint8List data, int offset) {
return data[offset] |
(data[offset + 1] << 8) |
(data[offset + 2] << 16) |
(data[offset + 3] << 24);
}
// // Helper to read uint16 little-endian
int readUint16LE(Uint8List data, int offset) {
return data[offset] | (data[offset + 1] << 8);
}
String _frameDetail(int code, Uint8List frame) {
switch (code) {
case respCodeSent:

View file

@ -11,7 +11,7 @@ import 'app_debug_log_service.dart';
class _AckHistoryEntry {
final String messageId;
final List<Uint8List> ackHashes;
final List<int> ackHashes;
final DateTime timestamp;
_AckHistoryEntry({
@ -77,7 +77,7 @@ class MessageRetryService extends ChangeNotifier {
final Map<String, Contact> _pendingContacts = {};
final Map<String, List<PathSelection>> _attemptPathHistory = {};
final Map<String, AckHashMapping> _ackHashToMessageId = {};
final Map<String, List<Uint8List>> _expectedAckHashes = {};
final Map<String, List<int>> _expectedAckHashes = {};
final List<_AckHistoryEntry> _ackHistory = [];
final Map<String, List<String>> _sendQueue = {};
final Set<String> _activeMessages = {};
@ -341,13 +341,11 @@ class MessageRetryService extends ChangeNotifier {
config.sendMessage(contact, message.text, attempt, timestampSeconds);
}
bool updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
bool updateMessageFromSent(int ackHash, int timeoutMs) {
final config = _config;
if (config == null) return false;
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final ackHashHex = ackHash.toRadixString(16).padLeft(8, '0');
// Try hash-based matching (fixes LoRa message drops causing mismatches)
String? messageId = _expectedHashToMessageId.remove(ackHashHex);
@ -389,10 +387,8 @@ class MessageRetryService extends ChangeNotifier {
// Add this ACK hash to the list of expected ACKs for this message (for history)
_expectedAckHashes[messageId] ??= [];
if (!_expectedAckHashes[messageId]!.any(
(hash) => listEquals(hash, ackHash),
)) {
_expectedAckHashes[messageId]!.add(Uint8List.fromList(ackHash));
if (!_expectedAckHashes[messageId]!.any((hash) => hash == ackHash)) {
_expectedAckHashes[messageId]!.add(ackHash);
}
// Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
@ -559,10 +555,10 @@ class MessageRetryService extends ChangeNotifier {
}
}
bool _checkAckHistory(Uint8List ackHash) {
bool _checkAckHistory(int ackHash) {
for (final entry in _ackHistory) {
for (final expectedHash in entry.ackHashes) {
if (listEquals(expectedHash, ackHash)) {
if (expectedHash == ackHash) {
return true;
}
}
@ -570,13 +566,11 @@ class MessageRetryService extends ChangeNotifier {
return false;
}
void handleAckReceived(Uint8List ackHash, int tripTimeMs) {
void handleAckReceived(int ackHash, int tripTimeMs) {
final config = _config;
String? matchedMessageId;
int? matchedAttemptIndex;
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final ackHashHex = ackHash.toRadixString(16).padLeft(8, '0');
// Clean up old ACK hash mappings (older than 15 minutes)
final cutoffTime = DateTime.now().subtract(const Duration(minutes: 15));
@ -606,7 +600,7 @@ class MessageRetryService extends ChangeNotifier {
final expectedHashes = entry.value;
for (final expectedHash in expectedHashes) {
if (listEquals(expectedHash, ackHash)) {
if (expectedHash == ackHash) {
matchedMessageId = messageId;
matchedAttemptIndex = expectedHashes.indexOf(expectedHash);
break;
@ -689,7 +683,7 @@ class MessageRetryService extends ChangeNotifier {
for (var entry in _pendingMessages.entries) {
final message = entry.value;
if (message.expectedAckHash != null &&
listEquals(message.expectedAckHash, ackHash)) {
message.expectedAckHash == ackHash) {
final contact = _pendingContacts[entry.key];
return contact?.publicKeyHex;
}

View file

@ -85,9 +85,7 @@ class MessageStore {
'messageId': msg.messageId,
'retryCount': msg.retryCount,
'estimatedTimeoutMs': msg.estimatedTimeoutMs,
'expectedAckHash': msg.expectedAckHash != null
? base64Encode(msg.expectedAckHash!)
: null,
'expectedAckHash': msg.expectedAckHash,
'sentAt': msg.sentAt?.millisecondsSinceEpoch,
'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch,
'tripTimeMs': msg.tripTimeMs,
@ -119,9 +117,7 @@ class MessageStore {
messageId: json['messageId'] as String?,
retryCount: json['retryCount'] as int? ?? 0,
estimatedTimeoutMs: json['estimatedTimeoutMs'] as int?,
expectedAckHash: json['expectedAckHash'] != null
? Uint8List.fromList(base64Decode(json['expectedAckHash'] as String))
: null,
expectedAckHash: json['expectedAckHash'] as int? ?? 0,
sentAt: json['sentAt'] != null
? DateTime.fromMillisecondsSinceEpoch(json['sentAt'] as int)
: null,

View file

@ -10,6 +10,14 @@ class DebugFrameViewer {
Uint8List frame,
String title,
) {
// Helper to read uint32 little-endian
int readUint32LE(Uint8List data, int offset) {
return data[offset] |
(data[offset + 1] << 8) |
(data[offset + 2] << 16) |
(data[offset + 3] << 24);
}
final hexString = frame
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(' ');