feat: Enhance MeshCoreBleService with new callbacks and message handling

- Added new callback types for path updates, message sent, message delivered, status responses, binary responses, and battery/storage information.
- Implemented handling for binary responses and path updates, including parsing and notifying via callbacks.
- Updated message sending logic to include acknowledgment and delivery confirmation.
- Enhanced log parsing for received data, including detailed interpretations and analysis.
- Introduced status request functionality to query operational status from repeater or sensor nodes.
- Updated battery and storage information handling to provide detailed metrics and trigger callbacks.
- Deprecated legacy methods in favor of more robust alternatives.
This commit is contained in:
Janez T
2025-10-15 09:31:19 +02:00
parent 05b90d7f1f
commit 9124b53073
17 changed files with 5685 additions and 190 deletions

View File

@@ -24,6 +24,12 @@ typedef OnMessageWaitingCallback = void Function();
typedef OnLoginSuccessCallback = void Function(Uint8List publicKeyPrefix, int permissions, bool isAdmin, int tag);
typedef OnLoginFailCallback = void Function(Uint8List publicKeyPrefix);
typedef OnAdvertReceivedCallback = void Function(Uint8List publicKey);
typedef OnPathUpdatedCallback = void Function(Uint8List publicKey);
typedef OnMessageSentCallback = void Function(int expectedAckTag, int suggestedTimeoutMs, bool isFloodMode);
typedef OnMessageDeliveredCallback = void Function(int ackCode, int roundTripTimeMs);
typedef OnStatusResponseCallback = void Function(Uint8List publicKeyPrefix, Uint8List statusData);
typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData);
typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb);
typedef OnErrorCallback = void Function(String error);
typedef OnConnectionStateCallback = void Function(bool isConnected);
@@ -47,6 +53,12 @@ class MeshCoreBleService {
OnLoginSuccessCallback? onLoginSuccess;
OnLoginFailCallback? onLoginFail;
OnAdvertReceivedCallback? onAdvertReceived;
OnPathUpdatedCallback? onPathUpdated;
OnMessageSentCallback? onMessageSent;
OnMessageDeliveredCallback? onMessageDelivered;
OnStatusResponseCallback? onStatusResponse;
OnBinaryResponseCallback? onBinaryResponse;
OnBatteryAndStorageCallback? onBatteryAndStorage;
OnErrorCallback? onError;
// Internal state
@@ -337,6 +349,10 @@ class MeshCoreBleService {
print(' → Handling TelemetryResponse');
_handleTelemetryResponse(reader);
break;
case MeshCoreConstants.pushBinaryResponse:
print(' → Handling BinaryResponse');
_handleBinaryResponse(reader);
break;
case MeshCoreConstants.respDeviceInfo:
print(' → Handling DeviceInfo');
_handleDeviceInfo(reader);
@@ -349,6 +365,10 @@ class MeshCoreBleService {
print(' → Handling Advert push');
_handleAdvert(reader);
break;
case MeshCoreConstants.pushPathUpdated:
print(' → Handling PathUpdated push');
_handlePathUpdated(reader);
break;
case MeshCoreConstants.pushLogRxData:
print(' → Handling LogRxData push');
_handleLogRxData(reader);
@@ -373,10 +393,18 @@ class MeshCoreBleService {
print(' → Handling LoginFail push');
_handleLoginFail(reader);
break;
case MeshCoreConstants.pushStatusResponse:
print(' → Handling StatusResponse push');
_handleStatusResponse(reader);
break;
case MeshCoreConstants.respCurrTime:
print(' → Handling CurrentTime');
_handleCurrentTime(reader);
break;
case MeshCoreConstants.respBatteryVoltage:
print(' → Handling BatteryAndStorage');
_handleBatteryAndStorage(reader);
break;
case MeshCoreConstants.respNoMoreMessages:
print(' → Response: No More Messages');
onNoMoreMessages?.call();
@@ -488,17 +516,20 @@ class MeshCoreBleService {
if (reader.remainingBytesCount >= 9) {
final sendType = reader.readByte();
final sendTypeStr = sendType == 1 ? 'flood' : 'direct';
final isFloodMode = sendType == 1;
print(' Send type: $sendType ($sendTypeStr)');
final expectedAckOrTag = reader.readBytes(4);
print(' Expected ACK/TAG: ${expectedAckOrTag.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
final expectedAckOrTagBytes = reader.readBytes(4);
final expectedAckTag = ByteData.sublistView(Uint8List.fromList(expectedAckOrTagBytes)).getUint32(0, Endian.little);
print(' Expected ACK/TAG: ${expectedAckOrTagBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')} (uint32: $expectedAckTag)');
final suggestedTimeout = reader.readUInt32LE();
print(' Suggested timeout: ${suggestedTimeout}ms');
print(' ✅ [Sent] Message sent successfully ($sendTypeStr mode, timeout: ${suggestedTimeout}ms)');
// TODO: Store ACK/TAG to match with PUSH_CODE_SEND_CONFIRMED later
// Notify provider that message was sent
onMessageSent?.call(expectedAckTag, suggestedTimeout, isFloodMode);
} else {
print(' ⚠️ [Sent] Insufficient data for full parsing');
}
@@ -529,27 +560,29 @@ class MeshCoreBleService {
// Handle different message types
String text;
Uint8List? signature;
Uint8List? senderPrefixExtra;
if (txtType == MessageTextType.signedPlain) {
// Signed message format: [64-byte signature][UTF-8 text]
print(' Signed message detected - extracting signature');
// Signed message format: [4-byte sender prefix][UTF-8 text]
// Note: Despite the name "signed", this doesn't contain a cryptographic signature
// It contains 4 extra bytes of the sender's public key prefix for verification
print(' Signed message detected - extracting extra sender prefix');
if (reader.remainingBytesCount < 64) {
print(' ⚠️ Insufficient bytes for signature (${reader.remainingBytesCount} < 64)');
// Try to read as plain text anyway
text = reader.readString();
} else {
signature = reader.readBytes(64);
print(' Signature (first 16 bytes): ${signature.sublist(0, 16).map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}...');
if (reader.remainingBytesCount >= 4) {
senderPrefixExtra = reader.readBytes(4);
print(' Extra sender prefix (4 bytes): ${senderPrefixExtra.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
// Remaining bytes are the actual text
if (reader.hasRemaining) {
text = reader.readString();
} else {
text = '';
print(' ⚠️ No text content after signature');
print(' ⚠️ No text content after sender prefix');
}
} else {
print(' ⚠️ Insufficient bytes for sender prefix (${reader.remainingBytesCount} < 4)');
// Read remaining bytes as text anyway
text = reader.readString();
}
} else {
// Plain text message
@@ -598,27 +631,29 @@ class MeshCoreBleService {
// Handle different message types
String text;
Uint8List? signature;
Uint8List? senderPrefixExtra;
if (txtType == MessageTextType.signedPlain) {
// Signed message format: [64-byte signature][UTF-8 text]
print(' Signed message detected - extracting signature');
// Signed message format: [4-byte sender prefix][UTF-8 text]
// Note: Despite the name "signed", this doesn't contain a cryptographic signature
// It contains 4 extra bytes of the sender's public key prefix for verification
print(' Signed message detected - extracting extra sender prefix');
if (reader.remainingBytesCount < 64) {
print(' ⚠️ Insufficient bytes for signature (${reader.remainingBytesCount} < 64)');
// Try to read as plain text anyway
text = reader.readString();
} else {
signature = reader.readBytes(64);
print(' Signature (first 16 bytes): ${signature.sublist(0, 16).map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}...');
if (reader.remainingBytesCount >= 4) {
senderPrefixExtra = reader.readBytes(4);
print(' Extra sender prefix (4 bytes): ${senderPrefixExtra.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
// Remaining bytes are the actual text
if (reader.hasRemaining) {
text = reader.readString();
} else {
text = '';
print(' ⚠️ No text content after signature');
print(' ⚠️ No text content after sender prefix');
}
} else {
print(' ⚠️ Insufficient bytes for sender prefix (${reader.remainingBytesCount} < 4)');
// Read remaining bytes as text anyway
text = reader.readString();
}
} else {
// Plain text message
@@ -670,6 +705,41 @@ class MeshCoreBleService {
}
}
/// Handle BinaryResponse push (PUSH_CODE_BINARY_RESPONSE 0x8C)
///
/// Protocol format:
/// - 1 byte: reserved (zero)
/// - 4 bytes: tag (uint32, matches RESP_CODE_SENT expected_ack_or_tag)
/// - N bytes: response data (remainder of frame)
void _handleBinaryResponse(BufferReader reader) {
try {
print(' [BinaryResponse] Parsing binary response...');
print(' Remaining bytes: ${reader.remainingBytesCount}');
final reserved = reader.readByte();
print(' Reserved byte: $reserved');
final tag = reader.readUInt32LE();
print(' Tag: $tag (matches RESP_CODE_SENT expected_ack_or_tag)');
final responseData = reader.readRemainingBytes();
print(' Response data length: ${responseData.length} bytes');
print(' Response data (hex): ${responseData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
// Extract public key prefix from response data if present
// Note: The firmware doesn't include the sender's public key prefix in binary responses
// The app must track which request corresponds to which tag
// For now, we'll use an empty prefix and rely on the tag for matching
final emptyPrefix = Uint8List(6);
print(' ✅ [BinaryResponse] Parsed successfully');
onBinaryResponse?.call(emptyPrefix, tag, responseData);
} catch (e) {
print(' ❌ [BinaryResponse] Parsing error: $e');
onError?.call('Binary response parsing error: $e');
}
}
/// Handle DeviceInfo response
/// Handle DeviceInfo response (RESP_CODE_DEVICE_INFO)
///
@@ -934,24 +1004,111 @@ class MeshCoreBleService {
}
}
/// Handle PathUpdated push (PUSH_CODE_PATH_UPDATED)
///
/// This push notification indicates that the mesh network has discovered
/// a new or better routing path to a contact. The companion radio sends
/// this notification when a contact's out_path is updated.
///
/// Protocol format:
/// - 32 bytes: public key of the contact whose path was updated
///
/// The app can use this to:
/// - Trigger a contact sync to get the updated path
/// - Show network topology changes in the UI
/// - Update signal quality indicators
void _handlePathUpdated(BufferReader reader) {
try {
print(' [PathUpdated] Parsing path updated push notification...');
print(' Remaining bytes: ${reader.remainingBytesCount}');
// PathUpdated format: 32 bytes public key
if (reader.remainingBytesCount >= 32) {
final publicKey = reader.readBytes(32);
final publicKeyPrefix = publicKey.sublist(0, 6);
final publicKeyFull = publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
print(' 📡 PATH UPDATED FOR CONTACT:');
print(' Public key prefix (6 bytes): ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
print(' Public key (full 32 bytes): $publicKeyFull');
print(' The mesh network has discovered a new/better routing path to this contact');
print(' The companion radio has updated the contact\'s out_path');
print(' Recommended action: Call CMD_GET_CONTACTS to sync the updated contact info');
// Notify callback so app can trigger contact sync or update UI
onPathUpdated?.call(publicKey);
} else {
print(' ⚠️ [PathUpdated] Insufficient data: expected 32 bytes, got ${reader.remainingBytesCount}');
}
// Consume any remaining bytes
if (reader.hasRemaining) {
final extraBytes = reader.readRemainingBytes();
print(' ⚠️ [PathUpdated] Extra bytes found: ${extraBytes.length} bytes');
print(' Extra data (hex): ${extraBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
}
print(' ✅ [PathUpdated] Parsed successfully');
} catch (e) {
print(' ❌ [PathUpdated] Parsing error: $e');
// Don't call onError - path updates are informational
}
}
/// Handle LogRxData push (PUSH_CODE_LOG_RX_DATA)
///
/// This push notification contains diagnostic/debug data from the companion radio
/// about packets it received over the air. The format is device-specific and may
/// contain encrypted or encoded data from the radio firmware.
/// This push notification contains diagnostic data about packets received over-the-air.
/// Based on MyMesh.cpp logRxRaw() implementation:
///
/// Frame format (after 0x88 opcode):
/// - Byte 0: SNR × 4 (signed int8, divide by 4 to get SNR in dB)
/// - Byte 1: RSSI (signed int8, in dBm)
/// - Bytes 2+: Raw over-the-air packet data (encrypted mesh packet)
///
/// The "raw" data is the actual LoRa packet received from another mesh node,
/// which is typically encrypted and has high entropy.
void _handleLogRxData(BufferReader reader) {
try {
print(' [LogRxData] Parsing log rx data...');
print(' [LogRxData] Parsing log rx data from over-the-air packet...');
print(' Remaining bytes: ${reader.remainingBytesCount}');
final data = reader.readRemainingBytes();
print(' Data length: ${data.length} bytes');
// Parse signal quality metrics (first 2 bytes)
if (data.length < 2) {
print(' ⚠️ [LogRxData] Insufficient data (need at least 2 bytes for SNR+RSSI)');
return;
}
final snrRaw = data[0];
final snrDb = (snrRaw.toSigned(8)) / 4.0; // Convert from int8 and divide by 4
print(' SNR: ${snrDb.toStringAsFixed(2)} dB (raw byte: 0x${snrRaw.toRadixString(16).padLeft(2, '0')})');
final rssiDbm = data[1].toSigned(8); // Signed int8
print(' RSSI: $rssiDbm dBm (raw byte: 0x${data[1].toRadixString(16).padLeft(2, '0')})');
// Remaining bytes are the raw over-the-air packet
if (data.length <= 2) {
print(' ⚠️ [LogRxData] No raw packet data after signal metrics');
return;
}
final rawPacketData = data.sublist(2);
print(' Raw packet data: ${rawPacketData.length} bytes');
print(' This is the encrypted LoRa packet received from another mesh node');
// Variables to store decoded information
int? airtimeMs;
Uint8List? senderPublicKey;
int? ackCode;
final List<String> embeddedStrings = [];
// Enhanced hex dump with 16 bytes per line for readability
print(' 📊 HEX DUMP:');
for (int i = 0; i < data.length; i += 16) {
final end = (i + 16 < data.length) ? i + 16 : data.length;
final chunk = data.sublist(i, end);
print(' 📊 RAW PACKET HEX DUMP:');
for (int i = 0; i < rawPacketData.length; i += 16) {
final end = (i + 16 < rawPacketData.length) ? i + 16 : rawPacketData.length;
final chunk = rawPacketData.sublist(i, end);
// Offset column (4 hex digits)
final offset = i.toRadixString(16).padLeft(4, '0');
@@ -972,42 +1129,192 @@ class MeshCoreBleService {
print(' $offset: ${hexBytes.padRight(47)} | $ascii');
}
// Attempt to decode structure
print(' 🔍 STRUCTURE ANALYSIS:');
// 🔥 FORCED DECODING - Try ALL possible interpretations
print(' 🔥 FORCED DECODING - EXHAUSTIVE ANALYSIS:');
print('');
if (data.length >= 4) {
// Try to parse potential timestamp at beginning (uint32 LE)
final timestamp = ByteData.sublistView(Uint8List.fromList(data.sublist(0, 4)))
.getUint32(0, Endian.little);
print(' [Bytes 0-3] Potential timestamp (uint32 LE): $timestamp');
// ========== INTERPRETATION 1: All Possible uint32 Values ==========
print(' 🔍 [INTERPRETATION 1] All uint32 LE values at each offset:');
for (int offset = 0; offset <= rawPacketData.length - 4; offset++) {
final value = ByteData.sublistView(Uint8List.fromList(rawPacketData.sublist(offset, offset + 4))).getUint32(0, Endian.little);
final valueHex = '0x${value.toRadixString(16).padLeft(8, '0')}';
// Check if timestamp is reasonable (between 2020 and 2030)
String interpretation = '';
// Check if it's a valid timestamp
const minTimestamp = 1577836800; // 2020-01-01
const maxTimestamp = 1893456000; // 2030-01-01
if (timestamp >= minTimestamp && timestamp <= maxTimestamp) {
print(' As epoch: ${DateTime.fromMillisecondsSinceEpoch(timestamp * 1000)}');
print(' ✅ Valid timestamp!');
} else {
print(' ⚠️ Timestamp out of reasonable range (not epoch seconds)');
if (value >= minTimestamp && value <= maxTimestamp) {
final date = DateTime.fromMillisecondsSinceEpoch(value * 1000);
interpretation = ' → TIMESTAMP: $date';
} else if (value < 100000) {
interpretation = ' → Airtime/Duration: ${value}ms';
} else if (value > 900000000 && value < 1000000000) {
interpretation = ' → Radio freq: ${value / 1000} MHz';
}
print(' [Offset $offset] uint32: $value ($valueHex)$interpretation');
}
print('');
// ========== INTERPRETATION 2: All Possible int32 Values ==========
print(' 🔍 [INTERPRETATION 2] All int32 LE values (for GPS coordinates):');
for (int offset = 0; offset <= rawPacketData.length - 4; offset++) {
final value = ByteData.sublistView(Uint8List.fromList(rawPacketData.sublist(offset, offset + 4))).getInt32(0, Endian.little);
final latLon = value / 1000000.0;
String interpretation = '';
if (latLon >= -90 && latLon <= 90) {
interpretation = ' → Possible GPS: ${latLon.toStringAsFixed(6)}°';
}
print(' [Offset $offset] int32: $value${latLon.toStringAsFixed(6)}$interpretation');
}
print('');
// ========== INTERPRETATION 3: All uint16 Values ==========
print(' 🔍 [INTERPRETATION 3] All uint16 LE values:');
for (int offset = 0; offset <= rawPacketData.length - 2; offset++) {
final value = ByteData.sublistView(Uint8List.fromList(rawPacketData.sublist(offset, offset + 2))).getUint16(0, Endian.little);
print(' [Offset $offset] uint16: $value (0x${value.toRadixString(16).padLeft(4, '0')})');
}
print('');
// ========== INTERPRETATION 4: Byte Pair Analysis ==========
print(' 🔍 [INTERPRETATION 4] Byte pair correlation (detect patterns):');
final Map<int, List<int>> bytePairs = {};
for (int i = 0; i < rawPacketData.length - 1; i++) {
final key = rawPacketData[i];
bytePairs.putIfAbsent(key, () => []);
bytePairs[key]!.add(rawPacketData[i + 1]);
}
// Check if this contains a public key (32-byte sequence starting around byte 4)
if (data.length >= 36) {
final potentialPubKey = data.sublist(4, 36);
final pubKeyPrefix = potentialPubKey.sublist(0, 6);
print(' [Bytes 4-35] Potential public key (32 bytes):');
print(' Prefix: ${pubKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
print(' Full: ${potentialPubKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
print(' This might be the sender\'s public key from over-the-air packet');
// Find repeating patterns
final repeatingPatterns = bytePairs.entries.where((e) => e.value.length > 1);
if (repeatingPatterns.isNotEmpty) {
print(' Repeating byte transitions found:');
for (final entry in repeatingPatterns) {
print(' Byte 0x${entry.key.toRadixString(16).padLeft(2, '0')}${entry.value.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(', ')}');
}
} else {
print(' No repeating byte transitions (high randomness)');
}
print('');
// ========== INTERPRETATION 5: Nibble Distribution ==========
print(' 🔍 [INTERPRETATION 5] Nibble (half-byte) distribution:');
final Map<int, int> nibbleHist = {};
for (final byte in rawPacketData) {
final high = (byte >> 4) & 0x0F;
final low = byte & 0x0F;
nibbleHist[high] = (nibbleHist[high] ?? 0) + 1;
nibbleHist[low] = (nibbleHist[low] ?? 0) + 1;
}
final sortedNibbles = nibbleHist.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
print(' Top nibble frequencies:');
for (int i = 0; i < (sortedNibbles.length < 5 ? sortedNibbles.length : 5); i++) {
final entry = sortedNibbles[i];
final bar = '' * ((entry.value / sortedNibbles[0].value * 20).round());
print(' 0x${entry.key.toRadixString(16)}: ${entry.value.toString().padLeft(3)} $bar');
}
print('');
// ========== INTERPRETATION 6: XOR Pattern Detection ==========
print(' 🔍 [INTERPRETATION 6] XOR pattern detection (simple encryption):');
final List<int> xorKeys = [0x00, 0xFF, 0xAA, 0x55, 0x42, 0x69];
for (final xorKey in xorKeys) {
final xored = rawPacketData.map((b) => b ^ xorKey).toList();
final printableCount = xored.where((b) => b >= 32 && b <= 126).length;
final printableRatio = printableCount / xored.length;
if (printableRatio > 0.3) {
final preview = String.fromCharCodes(xored.take(20).map((b) => b >= 32 && b <= 126 ? b : 46));
print(' XOR key 0x${xorKey.toRadixString(16).padLeft(2, '0')}: ${(printableRatio * 100).toStringAsFixed(1)}% printable → "$preview..."');
}
}
print('');
// ========== INTERPRETATION 7: Sliding Window CRC/Checksum ==========
print(' 🔍 [INTERPRETATION 7] Checksum/CRC candidates (last 1-4 bytes):');
if (rawPacketData.length >= 2) {
// Try last byte as checksum
final lastByte = rawPacketData[rawPacketData.length - 1];
final payload = rawPacketData.sublist(0, rawPacketData.length - 1);
final simpleSum = payload.reduce((a, b) => (a + b) & 0xFF);
final xorSum = payload.reduce((a, b) => a ^ b);
print(' Last byte: 0x${lastByte.toRadixString(16).padLeft(2, '0')}');
print(' Simple sum (mod 256): 0x${simpleSum.toRadixString(16).padLeft(2, '0')} ${simpleSum == lastByte ? '✅ MATCH!' : ''}');
print(' XOR checksum: 0x${xorSum.toRadixString(16).padLeft(2, '0')} ${xorSum == lastByte ? '✅ MATCH!' : ''}');
}
if (rawPacketData.length >= 3) {
final last2 = ByteData.sublistView(Uint8List.fromList(rawPacketData.sublist(rawPacketData.length - 2))).getUint16(0, Endian.little);
print(' Last 2 bytes (uint16 LE): 0x${last2.toRadixString(16).padLeft(4, '0')} ($last2)');
}
print('');
// ========== INTERPRETATION 8: Bit Pattern Analysis ==========
print(' 🔍 [INTERPRETATION 8] Bit-level analysis:');
int bitCount1 = 0;
int bitCount0 = 0;
for (final byte in rawPacketData) {
for (int bit = 0; bit < 8; bit++) {
if ((byte & (1 << bit)) != 0) {
bitCount1++;
} else {
bitCount0++;
}
}
}
final bitRatio = bitCount1 / (bitCount0 + bitCount1);
print(' Bit 1 count: $bitCount1 (${(bitRatio * 100).toStringAsFixed(1)}%)');
print(' Bit 0 count: $bitCount0 (${((1 - bitRatio) * 100).toStringAsFixed(1)}%)');
print(' Balance: ${(bitRatio - 0.5).abs() < 0.05 ? '✅ Well-balanced (likely encrypted/random)' : '⚠️ Imbalanced (may have structure)'}');
print('');
// ========== INTERPRETATION 9: LoRa Modulation Params ==========
print(' 🔍 [INTERPRETATION 9] LoRa modulation parameter candidates:');
for (int i = 0; i < rawPacketData.length; i++) {
final byte = rawPacketData[i];
// Check if it could be spreading factor (7-12)
if (byte >= 7 && byte <= 12) {
print(' [Offset $i] Possible SF (Spreading Factor): $byte');
}
// Check if it could be coding rate (5-8)
if (byte >= 5 && byte <= 8) {
print(' [Offset $i] Possible CR (Coding Rate): $byte');
}
// Check if it could be bandwidth index (0-9)
if (byte >= 0 && byte <= 9) {
final bwValues = [7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500];
print(' [Offset $i] Possible BW index: $byte${bwValues[byte]} kHz');
}
}
print('');
// ========== Final Structure Analysis ==========
print(' 🔍 STRUCTURE ANALYSIS:');
// Calculate entropy to detect encryption
final uniqueBytes = rawPacketData.toSet().length;
final entropy = uniqueBytes / rawPacketData.length;
final isLikelyEncrypted = entropy > 0.7;
print(' Entropy: ${(entropy * 100).toStringAsFixed(1)}% (${uniqueBytes}/${rawPacketData.length} unique bytes)');
if (isLikelyEncrypted) {
print(' High entropy suggests encrypted or compressed data');
}
// Look for printable strings (runs of 4+ printable characters)
final strings = <String>[];
StringBuffer currentString = StringBuffer();
for (int i = 0; i < data.length; i++) {
final byte = data[i];
for (int i = 0; i < rawPacketData.length; i++) {
final byte = rawPacketData[i];
if (byte >= 32 && byte <= 126) {
// Printable ASCII
currentString.write(String.fromCharCode(byte));
@@ -1028,20 +1335,38 @@ class MeshCoreBleService {
print(' Embedded strings found:');
for (final str in strings) {
print(' → "$str"');
embeddedStrings.add(str);
}
} else {
print(' No printable strings found (likely encrypted/binary data)');
}
// Check if this might be an encrypted packet (high entropy)
final uniqueBytes = data.toSet().length;
final entropy = uniqueBytes / data.length;
print(' Entropy: ${(entropy * 100).toStringAsFixed(1)}% (${uniqueBytes}/${data.length} unique bytes)');
if (entropy > 0.7) {
print(' High entropy suggests encrypted or compressed data');
}
print(' ✅ [LogRxData] Forced decode complete');
print(' ✅ [LogRxData] Parsed successfully');
// Create decoded info for packet log
final logRxDataInfo = LogRxDataInfo(
airtimeMs: airtimeMs,
senderPublicKey: senderPublicKey,
ackCode: ackCode,
embeddedStrings: embeddedStrings,
entropy: entropy,
isLikelyEncrypted: isLikelyEncrypted,
);
// Update the most recent packet log entry with decoded information
if (_packetLogs.isNotEmpty) {
final lastLog = _packetLogs.last;
if (lastLog.responseCode == MeshCoreConstants.pushLogRxData) {
_packetLogs[_packetLogs.length - 1] = BlePacketLog(
timestamp: lastLog.timestamp,
rawData: lastLog.rawData,
direction: lastLog.direction,
responseCode: lastLog.responseCode,
description: lastLog.description,
logRxDataInfo: logRxDataInfo,
);
}
}
} catch (e) {
print(' ❌ [LogRxData] Parsing error: $e');
// Don't call onError - logs are informational
@@ -1132,15 +1457,17 @@ class MeshCoreBleService {
print(' Remaining bytes: ${reader.remainingBytesCount}');
if (reader.remainingBytesCount >= 8) {
final ackCode = reader.readBytes(4);
print(' ACK code: ${ackCode.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
final ackCodeBytes = reader.readBytes(4);
final ackCode = ByteData.sublistView(Uint8List.fromList(ackCodeBytes)).getUint32(0, Endian.little);
print(' ACK code: ${ackCodeBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')} (uint32: $ackCode)');
final roundTripTime = reader.readUInt32LE();
print(' Round trip time: ${roundTripTime}ms');
print(' ✅ [SendConfirmed] Message delivery confirmed (RTT: ${roundTripTime}ms)');
// TODO: Match ACK code with pending sends and notify UI
// Notify provider that message was delivered
onMessageDelivered?.call(ackCode, roundTripTime);
} else {
print(' ⚠️ [SendConfirmed] Insufficient data for full parsing');
}
@@ -1234,6 +1561,72 @@ class MeshCoreBleService {
}
}
/// Handle StatusResponse push (PUSH_CODE_STATUS_RESPONSE)
///
/// This push notification is received in response to CMD_SEND_STATUS_REQ.
/// It contains status information from a repeater or sensor node.
///
/// Protocol format (PUSH_CODE_STATUS_RESPONSE, 0x87):
/// - 1 byte: reserved (zero)
/// - 6 bytes: public key prefix (first 6 bytes of responding node)
/// - N bytes: status data (remainder of frame, format depends on node type)
///
/// The status data format is node-specific and may include:
/// - Repeater nodes: uptime, message counts, relay statistics
/// - Sensor nodes: sensor readings, battery level, operational state
/// - Room nodes: user counts, message storage stats
void _handleStatusResponse(BufferReader reader) {
try {
print(' [StatusResponse] Parsing status response...');
print(' Remaining bytes: ${reader.remainingBytesCount}');
if (reader.remainingBytesCount >= 7) {
final reserved = reader.readByte();
print(' Reserved: $reserved');
final publicKeyPrefix = reader.readBytes(6);
print(' Node public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
// Read remaining status data
final statusData = reader.readRemainingBytes();
print(' Status data: ${statusData.length} bytes');
print(' Status data (hex): ${statusData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
// Try to decode as ASCII text if printable
try {
final statusText = utf8.decode(statusData, allowMalformed: true);
if (statusText.isNotEmpty && _isPrintableAscii(statusText)) {
print(' Status data (text): $statusText');
}
} catch (e) {
// Not text data, that's fine
}
print(' ✅ [StatusResponse] Received status response from node');
onStatusResponse?.call(publicKeyPrefix, statusData);
} else {
print(' ⚠️ [StatusResponse] Insufficient data for full parsing');
}
} catch (e) {
print(' ❌ [StatusResponse] Parsing error: $e');
onError?.call('Status response parsing error: $e');
}
}
/// Check if a string contains only printable ASCII characters
bool _isPrintableAscii(String text) {
for (int i = 0; i < text.length; i++) {
final code = text.codeUnitAt(i);
if (code < 32 || code > 126) {
// Not printable ASCII (except newlines and tabs which are common)
if (code != 10 && code != 13 && code != 9) {
return false;
}
}
}
return true;
}
/// Handle CurrentTime response (RESP_CODE_CURR_TIME)
///
/// Protocol format:
@@ -1273,6 +1666,64 @@ class MeshCoreBleService {
}
}
/// Handle BatteryAndStorage response (RESP_CODE_BATT_AND_STORAGE)
///
/// Protocol format (RESP_CODE_BATT_AND_STORAGE, code 12):
/// - 2 bytes: Millivolts (uint16)
/// - 4 bytes: (Optional) Used KB (uint32)
/// - 4 bytes: (Optional) Total KB (uint32, zero if unknown)
void _handleBatteryAndStorage(BufferReader reader) {
try {
print(' [BatteryAndStorage] Parsing battery and storage info...');
print(' Remaining bytes: ${reader.remainingBytesCount}');
if (reader.remainingBytesCount >= 2) {
// Battery voltage is always present (uint16)
final millivolts = reader.readUInt16LE();
final voltage = millivolts / 1000.0;
print(' Battery: ${millivolts}mV (${voltage.toStringAsFixed(2)}V)');
// Storage fields are optional
int? usedKb;
int? totalKb;
if (reader.remainingBytesCount >= 8) {
// Both storage fields present
usedKb = reader.readUInt32LE();
totalKb = reader.readUInt32LE();
print(' Storage Used: ${usedKb}KB');
print(' Storage Total: ${totalKb}KB');
if (totalKb > 0) {
final usedPercent = (usedKb / totalKb) * 100.0;
final availableKb = totalKb - usedKb;
print(' Storage Available: ${availableKb}KB (${(100 - usedPercent).toStringAsFixed(1)}% free)');
print(' Storage Usage: ${usedPercent.toStringAsFixed(1)}%');
} else {
print(' Storage Total is 0 (size unknown)');
}
} else if (reader.remainingBytesCount >= 4) {
// Only used KB present
usedKb = reader.readUInt32LE();
print(' Storage Used: ${usedKb}KB');
print(' Storage Total: Not available');
} else {
print(' Storage: Not available');
}
// Trigger callback
onBatteryAndStorage?.call(millivolts, usedKb, totalKb);
print(' ✅ [BatteryAndStorage] Parsed successfully');
} else {
print(' ⚠️ [BatteryAndStorage] Insufficient data (need at least 2 bytes for battery)');
}
} catch (e) {
print(' ❌ [BatteryAndStorage] Parsing error: $e');
onError?.call('BatteryAndStorage parsing error: $e');
}
}
/// Handle Error response (RESP_CODE_ERR)
///
/// Protocol format:
@@ -1457,6 +1908,7 @@ class MeshCoreBleService {
/// Request telemetry from contact
/// [zeroHop] - if true, only direct connection (no mesh forwarding)
@Deprecated('Use sendBinaryRequest() instead for better functionality')
Future<void> requestTelemetry(Uint8List contactPublicKey, {bool zeroHop = false}) async {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendTelemetryReq);
@@ -1467,13 +1919,62 @@ class MeshCoreBleService {
await _writeData(writer.toBytes());
}
/// Get battery voltage
Future<void> getBatteryVoltage() async {
/// Send binary request to contact (CMD_SEND_BINARY_REQ)
///
/// Modern replacement for requestTelemetry() with better functionality.
/// Supports multiple request types including telemetry, access lists, and neighbors.
///
/// Protocol format:
/// - 1 byte: command code (50)
/// - 32 bytes: contact public key
/// - N bytes: request code and params (requestData)
///
/// Common request codes (first byte of requestData):
/// - 0x03: Get telemetry data (equivalent to old requestTelemetry)
/// - 0x04: Get average/min/max telemetry
/// - 0x05: Get access list
/// - 0x06: Get neighbors list
///
/// Response arrives via onBinaryResponse callback with matching tag.
///
/// Example - request telemetry:
/// ```dart
/// await sendBinaryRequest(
/// contactPublicKey: contact.publicKey,
/// requestData: Uint8List.fromList([0x03]), // BINARY_REQ_GET_TELEMETRY_DATA
/// );
/// ```
Future<void> sendBinaryRequest({
required Uint8List contactPublicKey,
required Uint8List requestData,
}) async {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendBinaryReq); // 0x32 (50)
writer.writeBytes(contactPublicKey); // 32 bytes
writer.writeBytes(requestData); // request code + params
await _writeData(writer.toBytes());
}
/// Get battery voltage and storage information
///
/// Sends CMD_GET_BATT_AND_STORAGE (20) to query:
/// - Battery voltage in millivolts (uint16)
/// - Used storage in KB (optional uint32)
/// - Total storage in KB (optional uint32, 0 if unknown)
///
/// Response arrives via onBatteryAndStorage callback
Future<void> getBatteryAndStorage() async {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetBatteryVoltage);
await _writeData(writer.toBytes());
}
/// Legacy method name for backward compatibility
@Deprecated('Use getBatteryAndStorage() instead')
Future<void> getBatteryVoltage() async {
await getBatteryAndStorage();
}
/// Sync next message from device queue
/// Returns true if a message was retrieved, false if no more messages
Future<void> syncNextMessage() async {
@@ -1592,20 +2093,20 @@ class MeshCoreBleService {
/// Send login request to room or repeater
///
/// This sends a PAYLOAD_TYPE_ANON_REQ packet via the companion radio.
/// The companion radio encodes it and sends it to the room server.
/// This sends a login request to the room server via the companion radio.
///
/// Protocol format (CMD_SEND_LOGIN):
/// **ACTUAL Protocol format (CMD_SEND_LOGIN):**
/// - 1 byte: command code (26)
/// - 4 bytes: sender timestamp (uint32, epoch seconds - current time)
/// - 4 bytes: sync_since timestamp (uint32, epoch seconds - 0 for all messages)
/// - 32 bytes: room public key
/// - N bytes: password (varchar, max 15 bytes, null-terminated)
///
/// NOTE: The documentation was wrong - there are NO timestamp/sync_since params
/// in the companion radio protocol. The companion radio's sendLogin() function
/// handles timestamp internally when it creates the PAYLOAD_TYPE_ANON_REQ packet.
///
/// Response: PUSH_CODE_LOGIN_SUCCESS (0x85) or PUSH_CODE_LOGIN_FAIL (0x86)
///
/// After successful login, the room server will PUSH messages where
/// post_timestamp > sync_since directly to the companion radio.
/// After successful login, the room server will automatically PUSH stored messages.
///
/// IMPORTANT: The companion radio must have the room contact in its own
/// internal contact table. If you get ERR_CODE_NOT_FOUND (2), the radio
@@ -1616,31 +2117,58 @@ class MeshCoreBleService {
Future<void> loginToRoom({
required Uint8List roomPublicKey,
required String password,
int syncSince = 0, // 0 = get all messages
}) async {
if (password.length > 15) {
throw ArgumentError('Password exceeds 15 character limit');
}
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; // epoch seconds
print('🔐 [BLE] Preparing login request:');
print(' Room public key prefix: ${roomPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
print(' Password: ${"*" * password.length} (${password.length} chars)');
print(' Sender timestamp: $now (${DateTime.fromMillisecondsSinceEpoch(now * 1000)})');
print(' Sync since: $syncSince (${syncSince == 0 ? "all messages" : "messages after timestamp $syncSince"})');
print(' ⚠️ NOTE: The companion radio must have this room in its contact table');
print(' If you get ERR_CODE_NOT_FOUND, the room needs to advertise first or use CMD_ADD_UPDATE_CONTACT');
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendLogin);
writer.writeUInt32LE(now); // sender timestamp
writer.writeUInt32LE(syncSince); // sync messages since this timestamp (0 = all)
writer.writeByte(MeshCoreConstants.cmdSendLogin); // 0x1A
writer.writeBytes(roomPublicKey); // 32 bytes
writer.writeString(password); // Max 15 bytes, null-terminated
await _writeData(writer.toBytes());
}
/// Send status request to repeater or sensor node
///
/// This sends a status request (CMD_SEND_STATUS_REQ, 0x1B) to a repeater
/// or sensor node to query its current operational status.
///
/// Protocol format (CMD_SEND_STATUS_REQ):
/// - 1 byte: command code (27)
/// - 32 bytes: public key of target node (repeater or sensor)
///
/// Response: PUSH_CODE_STATUS_RESPONSE (0x87) push notification
///
/// The status data format is node-specific:
/// - Repeater nodes: uptime, message counts, relay statistics
/// - Sensor nodes: sensor readings, battery level, operational state
/// - Room nodes: user counts, message storage statistics
///
/// Example usage:
/// ```dart
/// bleService.onStatusResponse = (publicKeyPrefix, statusData) {
/// print('Status from node: ${utf8.decode(statusData)}');
/// };
/// await bleService.sendStatusRequest(repeaterContact.publicKey);
/// ```
Future<void> sendStatusRequest(Uint8List contactPublicKey) async {
print('📊 [BLE] Preparing status request:');
print(' Target node public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
print(' Requesting status from repeater/sensor node');
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendStatusReq); // 0x1B
writer.writeBytes(contactPublicKey); // 32 bytes
await _writeData(writer.toBytes());
}
/// Log a packet
void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) {
// Add new packet
@@ -1675,6 +2203,8 @@ class MeshCoreBleService {
return 'Device Query';
case MeshCoreConstants.cmdAppStart:
return 'App Start';
case MeshCoreConstants.cmdSendStatusReq:
return 'Status Request';
default:
return null;
}
@@ -1701,10 +2231,14 @@ class MeshCoreBleService {
return 'Self Info';
case MeshCoreConstants.pushAdvert:
return 'Advertisement';
case MeshCoreConstants.pushPathUpdated:
return 'Path Updated';
case MeshCoreConstants.pushLogRxData:
return 'Log RX Data';
case MeshCoreConstants.pushNewAdvert:
return 'New Advertisement';
case MeshCoreConstants.pushStatusResponse:
return 'Status Response';
case MeshCoreConstants.respNoMoreMessages:
return 'No More Messages';
case MeshCoreConstants.respOk: