feat: Add Packet Log Screen for BLE packet logging and exporting

- Implemented PacketLogScreen to display and filter BLE packet logs.
- Added functionality to export logs as CSV and text files.
- Introduced clipboard copy feature for hex data.
- Implemented clear logs functionality with confirmation dialog.
- Enhanced MeshCoreBleService to log TX and RX packets with descriptions.
- Added BufferReader methods for reading unsigned and signed 16-bit integers (big-endian).
- Updated CayenneLppParser to read values as big-endian.
- Created MessageStorageService for persisting messages to local storage.
- Enhanced map markers to display telemetry data including voltage, humidity, and pressure.
This commit is contained in:
Janez T
2025-10-14 15:27:18 +02:00
parent ccec842672
commit 59de627289
20 changed files with 4960 additions and 153 deletions

View File

@@ -50,6 +50,22 @@ class BufferReader {
return value > 32767 ? value - 65536 : value;
}
/// Read unsigned 16-bit integer (big-endian)
int readUInt16BE() {
if (_offset + 2 > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final value = (_buffer[_offset] << 8) | _buffer[_offset + 1];
_offset += 2;
return value;
}
/// Read signed 16-bit integer (big-endian)
int readInt16BE() {
final value = readUInt16BE();
return value > 32767 ? value - 65536 : value;
}
/// Read unsigned 32-bit integer (little-endian)
int readUInt32LE() {
if (_offset + 4 > _buffer.length) {

View File

@@ -49,7 +49,7 @@ class CayenneLppParser {
break;
case MeshCoreConstants.lppAnalogInput:
final rawValue = reader.readInt16LE();
final rawValue = reader.readInt16BE();
final value = rawValue / 100.0;
print(' Analog Input (raw): $rawValue');
print(' Analog Input (volts): ${value}V');
@@ -63,7 +63,7 @@ class CayenneLppParser {
break;
case MeshCoreConstants.lppAnalogOutput:
final rawValue = reader.readInt16LE();
final rawValue = reader.readInt16BE();
final value = rawValue / 100.0;
print(' Analog Output (raw): $rawValue');
print(' Analog Output (volts): ${value}V');
@@ -71,7 +71,7 @@ class CayenneLppParser {
break;
case MeshCoreConstants.lppIlluminanceSensor:
final value = reader.readUInt16LE();
final value = reader.readUInt16BE();
print(' Illuminance: $value lux');
extraSensorData['illuminance_$channel'] = value;
break;
@@ -83,7 +83,7 @@ class CayenneLppParser {
break;
case MeshCoreConstants.lppTemperatureSensor:
final rawValue = reader.readInt16LE();
final rawValue = reader.readInt16BE();
temperature = rawValue / 10.0;
print(' Temperature (raw): $rawValue');
print(' Temperature: ${temperature?.toStringAsFixed(1)}°C');
@@ -97,22 +97,22 @@ class CayenneLppParser {
break;
case MeshCoreConstants.lppAccelerometer:
final x = reader.readInt16LE() / 1000.0;
final y = reader.readInt16LE() / 1000.0;
final z = reader.readInt16LE() / 1000.0;
final x = reader.readInt16BE() / 1000.0;
final y = reader.readInt16BE() / 1000.0;
final z = reader.readInt16BE() / 1000.0;
print(' Accelerometer: x=$x, y=$y, z=$z');
extraSensorData['accelerometer_$channel'] = {'x': x, 'y': y, 'z': z};
break;
case MeshCoreConstants.lppBarometer:
final rawValue = reader.readUInt16LE();
final rawValue = reader.readUInt16BE();
pressure = rawValue / 10.0;
print(' Barometer (raw): $rawValue');
print(' Barometer: ${pressure?.toStringAsFixed(1)} hPa');
break;
case MeshCoreConstants.lppVoltageSensor:
final rawValue = reader.readUInt16LE();
final rawValue = reader.readUInt16BE();
final value = rawValue / 100.0;
print(' Voltage (raw): $rawValue');
print(' Voltage: ${value}V');
@@ -123,9 +123,9 @@ class CayenneLppParser {
break;
case MeshCoreConstants.lppGyrometer:
final x = reader.readInt16LE() / 100.0;
final y = reader.readInt16LE() / 100.0;
final z = reader.readInt16LE() / 100.0;
final x = reader.readInt16BE() / 100.0;
final y = reader.readInt16BE() / 100.0;
final z = reader.readInt16BE() / 100.0;
print(' Gyrometer: x=$x, y=$y, z=$z');
extraSensorData['gyrometer_$channel'] = {'x': x, 'y': y, 'z': z};
break;

View File

@@ -5,6 +5,7 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../models/contact.dart';
import '../models/contact_telemetry.dart';
import '../models/message.dart';
import '../models/ble_packet_log.dart';
import 'buffer_reader.dart';
import 'buffer_writer.dart';
import 'meshcore_constants.dart';
@@ -15,6 +16,7 @@ typedef OnContactsCompleteCallback = void Function(List<Contact> contacts);
typedef OnMessageCallback = void Function(Message message);
typedef OnTelemetryCallback = void Function(Uint8List publicKey, Uint8List lppData);
typedef OnSelfInfoCallback = void Function(Map<String, dynamic> selfInfo);
typedef OnNoMoreMessagesCallback = void Function();
typedef OnErrorCallback = void Function(String error);
typedef OnConnectionStateCallback = void Function(bool isConnected);
@@ -32,6 +34,7 @@ class MeshCoreBleService {
OnMessageCallback? onMessageReceived;
OnTelemetryCallback? onTelemetryReceived;
OnSelfInfoCallback? onSelfInfoReceived;
OnNoMoreMessagesCallback? onNoMoreMessages;
OnErrorCallback? onError;
// Internal state
@@ -49,6 +52,11 @@ class MeshCoreBleService {
VoidCallback? onRxActivity;
VoidCallback? onTxActivity;
// Packet logging
final List<BlePacketLog> _packetLogs = [];
List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs);
static const int _maxLogSize = 1000; // Keep last 1000 packets
/// Scan for MeshCore devices
Stream<BluetoothDevice> scanForDevices({Duration timeout = const Duration(seconds: 10)}) async* {
try {
@@ -238,6 +246,10 @@ class MeshCoreBleService {
throw Exception('Characteristic does not support write operations');
}
// Log TX packet (extract command code from first byte)
final commandCode = data.isNotEmpty ? data[0] : null;
_logPacket(data, PacketDirection.tx, responseCode: commandCode);
// Increment TX packet counter and trigger activity indicator
_txPacketCount++;
onTxActivity?.call();
@@ -261,17 +273,22 @@ class MeshCoreBleService {
return;
}
final dataBytes = Uint8List.fromList(data);
// Increment RX packet counter and trigger activity indicator
_rxPacketCount++;
onRxActivity?.call();
print(' Raw data: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
final reader = BufferReader(Uint8List.fromList(data));
final reader = BufferReader(dataBytes);
final responseCode = reader.readByte();
print(' Response code: $responseCode (0x${responseCode.toRadixString(16)})');
print(' Remaining bytes: ${reader.remainingBytesCount}');
// Log RX packet (before processing so we capture everything)
_logPacket(dataBytes, PacketDirection.rx, responseCode: responseCode);
switch (responseCode) {
case MeshCoreConstants.respContactsStart:
print(' → Handling ContactsStart');
@@ -317,6 +334,14 @@ class MeshCoreBleService {
print(' → Handling LogRxData push');
_handleLogRxData(reader);
break;
case MeshCoreConstants.pushNewAdvert:
print(' → Handling NewAdvert push');
_handleNewAdvert(reader);
break;
case MeshCoreConstants.respNoMoreMessages:
print(' → Response: No More Messages');
onNoMoreMessages?.call();
break;
case MeshCoreConstants.respOk:
print(' → Response: OK');
break;
@@ -708,6 +733,79 @@ class MeshCoreBleService {
}
}
/// Handle NewAdvert push
void _handleNewAdvert(BufferReader reader) {
try {
print(' [NewAdvert] Parsing new advertisement...');
print(' Remaining bytes: ${reader.remainingBytesCount}');
// NewAdvert format is identical to Contact response:
// - 32 bytes: public key
// - 1 byte: type
// - 1 byte: flags
// - 1 byte: outPathLen
// - 64 bytes: outPath
// - 32 bytes: advName (null-terminated string)
// - 4 bytes: lastAdvert (uint32)
// - 4 bytes: advLat (int32)
// - 4 bytes: advLon (int32)
// - 4 bytes: lastMod (uint32)
final publicKey = reader.readBytes(32);
print(' Public key prefix: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
final typeByte = reader.readByte();
final type = ContactType.fromValue(typeByte);
print(' Type byte: $typeByte → Type: $type');
final flags = reader.readByte();
print(' Flags: $flags (0x${flags.toRadixString(16).padLeft(2, '0')})');
final outPathLen = reader.readInt8();
print(' Out path length: $outPathLen');
final outPath = reader.readBytes(64);
print(' Out path: ${outPath.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...');
final advName = reader.readCString(32);
print(' Advertised name: "$advName"');
final lastAdvert = reader.readUInt32LE();
print(' Last advert timestamp: $lastAdvert');
final advLat = reader.readInt32LE();
print(' Latitude (raw int32): $advLat');
print(' Latitude (decimal): ${advLat / 1000000.0}°');
final advLon = reader.readInt32LE();
print(' Longitude (raw int32): $advLon');
print(' Longitude (decimal): ${advLon / 1000000.0}°');
final lastMod = reader.readUInt32LE();
print(' Last modified timestamp: $lastMod');
final contact = Contact(
publicKey: publicKey,
type: type,
flags: flags,
outPathLen: outPathLen,
outPath: outPath,
advName: advName,
lastAdvert: lastAdvert,
advLat: advLat,
advLon: advLon,
lastMod: lastMod,
);
print(' ✅ [NewAdvert] Parsed successfully - new contact advertised on network');
// Call the contact received callback to add/update the contact
onContactReceived?.call(contact);
} catch (e) {
print(' ❌ [NewAdvert] Parsing error: $e');
onError?.call('NewAdvert parsing error: $e');
}
}
/// Send AppStart command
Future<void> _sendAppStart() async {
print('📤 [BLE] Preparing AppStart command...');
@@ -780,10 +878,11 @@ class MeshCoreBleService {
}
/// Request telemetry from contact
Future<void> requestTelemetry(Uint8List contactPublicKey) async {
/// [zeroHop] - if true, only direct connection (no mesh forwarding)
Future<void> requestTelemetry(Uint8List contactPublicKey, {bool zeroHop = false}) async {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendTelemetryReq);
writer.writeByte(0); // reserved
writer.writeByte(zeroHop ? 0 : 255); // hop count: 0 = direct only, 255 = unlimited
writer.writeByte(0); // reserved
writer.writeByte(0); // reserved
writer.writeBytes(contactPublicKey);
@@ -797,6 +896,14 @@ class MeshCoreBleService {
await _writeData(writer.toBytes());
}
/// Sync next message from device queue
/// Returns true if a message was retrieved, false if no more messages
Future<void> syncNextMessage() async {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSyncNextMessage);
await _writeData(writer.toBytes());
}
/// Set device time
Future<void> setDeviceTime() async {
final writer = BufferWriter();
@@ -862,6 +969,87 @@ class MeshCoreBleService {
await _writeData(writer.toBytes());
}
/// Log a packet
void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) {
// Add new packet
_packetLogs.add(BlePacketLog(
timestamp: DateTime.now(),
rawData: data,
direction: direction,
responseCode: responseCode,
description: _getPacketDescription(responseCode, direction),
));
// Limit log size to prevent memory issues
if (_packetLogs.length > _maxLogSize) {
_packetLogs.removeAt(0);
}
}
/// Get human-readable description of packet
String? _getPacketDescription(int? code, PacketDirection direction) {
if (direction == PacketDirection.tx) {
// TX packets - command codes
switch (code) {
case MeshCoreConstants.cmdGetContacts:
return 'Get Contacts';
case MeshCoreConstants.cmdSendTxtMsg:
return 'Send Text Message';
case MeshCoreConstants.cmdSendChannelTxtMsg:
return 'Send Channel Message';
case MeshCoreConstants.cmdSendTelemetryReq:
return 'Request Telemetry';
case MeshCoreConstants.cmdDeviceQuery:
return 'Device Query';
case MeshCoreConstants.cmdAppStart:
return 'App Start';
default:
return null;
}
} else {
// RX packets - response codes
switch (code) {
case MeshCoreConstants.respContactsStart:
return 'Contacts Start';
case MeshCoreConstants.respContact:
return 'Contact Info';
case MeshCoreConstants.respEndOfContacts:
return 'End of Contacts';
case MeshCoreConstants.respSent:
return 'Message Sent';
case MeshCoreConstants.respContactMsgRecv:
return 'Contact Message';
case MeshCoreConstants.respChannelMsgRecv:
return 'Channel Message';
case MeshCoreConstants.pushTelemetryResponse:
return 'Telemetry Data';
case MeshCoreConstants.respDeviceInfo:
return 'Device Info';
case MeshCoreConstants.respSelfInfo:
return 'Self Info';
case MeshCoreConstants.pushAdvert:
return 'Advertisement';
case MeshCoreConstants.pushLogRxData:
return 'Log RX Data';
case MeshCoreConstants.pushNewAdvert:
return 'New Advertisement';
case MeshCoreConstants.respNoMoreMessages:
return 'No More Messages';
case MeshCoreConstants.respOk:
return 'OK';
case MeshCoreConstants.respErr:
return 'ERROR';
default:
return null;
}
}
}
/// Clear packet logs
void clearPacketLogs() {
_packetLogs.clear();
}
/// Reset packet counters
void resetCounters() {
_rxPacketCount = 0;
@@ -872,5 +1060,6 @@ class MeshCoreBleService {
void dispose() {
_txSubscription?.cancel();
_pendingContacts.clear();
_packetLogs.clear();
}
}

View File

@@ -0,0 +1,164 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/message.dart';
import '../models/sar_marker.dart';
import 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage
class MessageStorageService {
static const String _messagesKey = 'stored_messages';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
/// Save messages to persistent storage
Future<void> saveMessages(List<Message> messages) async {
try {
final prefs = await SharedPreferences.getInstance();
// Convert messages to JSON
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
// Limit to max stored messages (keep most recent)
final limitedList = jsonList.length > _maxStoredMessages
? jsonList.sublist(jsonList.length - _maxStoredMessages)
: jsonList;
final jsonString = jsonEncode(limitedList);
await prefs.setString(_messagesKey, jsonString);
print('✅ [MessageStorage] Saved ${limitedList.length} messages to storage');
} catch (e) {
print('❌ [MessageStorage] Error saving messages: $e');
}
}
/// Load messages from persistent storage
Future<List<Message>> loadMessages() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
print(' [MessageStorage] No stored messages found');
return [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
final messages = jsonList
.map((json) => _messageFromJson(json as Map<String, dynamic>))
.where((msg) => msg != null)
.cast<Message>()
.toList();
print('✅ [MessageStorage] Loaded ${messages.length} messages from storage');
return messages;
} catch (e) {
print('❌ [MessageStorage] Error loading messages: $e');
return [];
}
}
/// Clear all stored messages
Future<void> clearMessages() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_messagesKey);
print('✅ [MessageStorage] Cleared all stored messages');
} catch (e) {
print('❌ [MessageStorage] Error clearing messages: $e');
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return {
'messageCount': 0,
'storageSizeBytes': 0,
'storageSizeKB': 0,
};
}
final sizeBytes = jsonString.length;
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return {
'messageCount': jsonList.length,
'storageSizeBytes': sizeBytes,
'storageSizeKB': (sizeBytes / 1024).toStringAsFixed(2),
};
} catch (e) {
print('❌ [MessageStorage] Error getting storage stats: $e');
return {
'messageCount': 0,
'storageSizeBytes': 0,
'storageSizeKB': 0,
};
}
}
/// Convert Message to JSON
Map<String, dynamic> _messageToJson(Message message) {
return {
'id': message.id,
'messageType': message.messageType.name,
'senderPublicKeyPrefix': message.senderPublicKeyPrefix != null
? base64Encode(message.senderPublicKeyPrefix!)
: null,
'channelIdx': message.channelIdx,
'pathLen': message.pathLen,
'textType': message.textType.value,
'senderTimestamp': message.senderTimestamp,
'text': message.text,
'isSarMarker': message.isSarMarker,
'sarMarkerType': message.sarMarkerType?.name,
'sarGpsLat': message.sarGpsCoordinates?.latitude,
'sarGpsLon': message.sarGpsCoordinates?.longitude,
'receivedAtMillis': message.receivedAt.millisecondsSinceEpoch,
'senderName': message.senderName,
};
}
/// Convert JSON to Message
Message? _messageFromJson(Map<String, dynamic> json) {
try {
return Message(
id: json['id'] as String,
messageType: MessageType.values.firstWhere(
(e) => e.name == json['messageType'],
orElse: () => MessageType.contact,
),
senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null
? Uint8List.fromList(
base64Decode(json['senderPublicKeyPrefix'] as String))
: null,
channelIdx: json['channelIdx'] as int?,
pathLen: json['pathLen'] as int,
textType: MessageTextType.fromValue(json['textType'] as int),
senderTimestamp: json['senderTimestamp'] as int,
text: json['text'] as String,
isSarMarker: json['isSarMarker'] as bool? ?? false,
sarMarkerType: json['sarMarkerType'] != null
? SarMarkerType.values.firstWhere(
(e) => e.name == json['sarMarkerType'],
orElse: () => SarMarkerType.unknown,
)
: null,
sarGpsCoordinates: json['sarGpsLat'] != null &&
json['sarGpsLon'] != null
? LatLng(json['sarGpsLat'] as double, json['sarGpsLon'] as double)
: null,
receivedAt: DateTime.fromMillisecondsSinceEpoch(
json['receivedAtMillis'] as int),
senderName: json['senderName'] as String?,
);
} catch (e) {
print('❌ [MessageStorage] Error parsing message from JSON: $e');
return null;
}
}
}