From 373998bc7ddfd868fd4d45239a12e19f17e56d24 Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 14 Oct 2025 10:46:07 +0200 Subject: [PATCH] feat: Add RX/TX activity indicators and packet counters; update GPS coordinate conversion --- lib/models/contact.dart | 4 +- lib/providers/connection_provider.dart | 40 +++++++ lib/screens/home_screen.dart | 74 +++++++++--- lib/services/cayenne_lpp_parser.dart | 73 ++++++++++-- lib/services/meshcore_ble_service.dart | 157 +++++++++++++++++++++++-- lib/services/meshcore_constants.dart | 1 + 6 files changed, 316 insertions(+), 33 deletions(-) diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 7a28457..7b890fa 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -79,8 +79,8 @@ class Contact { LatLng? get advertLocation { if (advLat == 0 && advLon == 0) return null; // Convert from int32 to double (degrees) - final lat = advLat / 1e7; - final lon = advLon / 1e7; + final lat = advLat / 1e6; + final lon = advLon / 1e6; return LatLng(lat, lon); } diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 28312b8..1c2896e 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -28,6 +28,19 @@ class ConnectionProvider with ChangeNotifier { String? _error; String? get error => _error; + // Activity indicators (for blinking) + bool _rxActivity = false; + bool _txActivity = false; + bool get rxActivity => _rxActivity; + bool get txActivity => _txActivity; + + Timer? _rxActivityTimer; + Timer? _txActivityTimer; + + // Packet counters + int get rxPacketCount => _bleService.rxPacketCount; + int get txPacketCount => _bleService.txPacketCount; + // Callbacks for other providers Function(Contact)? onContactReceived; Function(List)? onContactsComplete; @@ -90,6 +103,31 @@ class ConnectionProvider with ChangeNotifier { _bleService.onTelemetryReceived = (publicKey, lppData) { onTelemetryReceived?.call(publicKey, lppData); }; + + // Activity indicators + _bleService.onRxActivity = () { + _rxActivity = true; + notifyListeners(); + + // Reset after 100ms + _rxActivityTimer?.cancel(); + _rxActivityTimer = Timer(const Duration(milliseconds: 100), () { + _rxActivity = false; + notifyListeners(); + }); + }; + + _bleService.onTxActivity = () { + _txActivity = true; + notifyListeners(); + + // Reset after 100ms + _txActivityTimer?.cancel(); + _txActivityTimer = Timer(const Duration(milliseconds: 100), () { + _txActivity = false; + notifyListeners(); + }); + }; } /// Start scanning for MeshCore devices @@ -269,6 +307,8 @@ class ConnectionProvider with ChangeNotifier { @override void dispose() { + _rxActivityTimer?.cancel(); + _txActivityTimer?.cancel(); _bleService.dispose(); super.dispose(); } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index aea9fab..1047578 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -377,21 +377,67 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ), ) else - OutlinedButton( - onPressed: () async { - await provider.disconnect(); - if (context.mounted) { - context.read().clearAllData(); - } - }, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // RX indicator + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: provider.rxActivity + ? Colors.green + : Colors.grey.withOpacity(0.3), + ), ), - ), - child: const Text('Disconnect'), + const SizedBox(width: 4), + Text( + 'RX:${provider.rxPacketCount}', + style: const TextStyle( + fontSize: 11, + color: Colors.grey, + ), + ), + const SizedBox(width: 12), + // TX indicator + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: provider.txActivity + ? Colors.blue + : Colors.grey.withOpacity(0.3), + ), + ), + const SizedBox(width: 4), + Text( + 'TX:${provider.txPacketCount}', + style: const TextStyle( + fontSize: 11, + color: Colors.grey, + ), + ), + const SizedBox(width: 12), + OutlinedButton( + onPressed: () async { + await provider.disconnect(); + if (context.mounted) { + context.read().clearAllData(); + } + }, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + child: const Text('Disconnect', style: TextStyle(fontSize: 13)), + ), + ], ), ], ); diff --git a/lib/services/cayenne_lpp_parser.dart b/lib/services/cayenne_lpp_parser.dart index d4d091b..1913076 100644 --- a/lib/services/cayenne_lpp_parser.dart +++ b/lib/services/cayenne_lpp_parser.dart @@ -9,6 +9,10 @@ import 'meshcore_constants.dart'; class CayenneLppParser { /// Parse Cayenne LPP data into ContactTelemetry static ContactTelemetry parse(Uint8List data) { + print(' [CayenneLPP] Parsing LPP data...'); + print(' Data length: ${data.length} bytes'); + print(' Data (hex): ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); + final reader = BufferReader(data); LatLng? gpsLocation; @@ -19,92 +23,145 @@ class CayenneLppParser { double? pressure; final extraSensorData = {}; + int fieldCount = 0; while (reader.hasRemaining) { try { + fieldCount++; + print(' [Field $fieldCount] Position: ${data.length - reader.remainingBytesCount}'); + final channel = reader.readByte(); + print(' Channel: $channel'); + final type = reader.readByte(); + print(' Type: $type (0x${type.toRadixString(16).padLeft(2, '0')})'); switch (type) { case MeshCoreConstants.lppDigitalInput: final value = reader.readByte(); + print(' Digital Input: $value'); extraSensorData['digital_input_$channel'] = value; break; case MeshCoreConstants.lppDigitalOutput: final value = reader.readByte(); + print(' Digital Output: $value'); extraSensorData['digital_output_$channel'] = value; break; case MeshCoreConstants.lppAnalogInput: - final value = reader.readInt16LE() / 100.0; + final rawValue = reader.readInt16LE(); + final value = rawValue / 100.0; + print(' Analog Input (raw): $rawValue'); + print(' Analog Input (volts): ${value}V'); extraSensorData['analog_input_$channel'] = value; // If this is a battery reading if (channel == 0 || channel == 1) { batteryMilliVolts = value * 1000; batteryPercentage = _calculateBatteryPercentage(value); + print(' → Battery: ${batteryPercentage?.toStringAsFixed(1)}% (${batteryMilliVolts?.toStringAsFixed(0)}mV)'); } break; case MeshCoreConstants.lppAnalogOutput: - final value = reader.readInt16LE() / 100.0; + final rawValue = reader.readInt16LE(); + final value = rawValue / 100.0; + print(' Analog Output (raw): $rawValue'); + print(' Analog Output (volts): ${value}V'); extraSensorData['analog_output_$channel'] = value; break; case MeshCoreConstants.lppIlluminanceSensor: final value = reader.readUInt16LE(); + print(' Illuminance: $value lux'); extraSensorData['illuminance_$channel'] = value; break; case MeshCoreConstants.lppPresenceSensor: final value = reader.readByte(); + print(' Presence: $value'); extraSensorData['presence_$channel'] = value; break; case MeshCoreConstants.lppTemperatureSensor: - temperature = reader.readInt16LE() / 10.0; + final rawValue = reader.readInt16LE(); + temperature = rawValue / 10.0; + print(' Temperature (raw): $rawValue'); + print(' Temperature: ${temperature?.toStringAsFixed(1)}°C'); break; case MeshCoreConstants.lppHumiditySensor: - humidity = reader.readByte() / 2.0; + final rawValue = reader.readByte(); + humidity = rawValue / 2.0; + print(' Humidity (raw): $rawValue'); + print(' Humidity: ${humidity?.toStringAsFixed(1)}%'); break; case MeshCoreConstants.lppAccelerometer: final x = reader.readInt16LE() / 1000.0; final y = reader.readInt16LE() / 1000.0; final z = reader.readInt16LE() / 1000.0; + print(' Accelerometer: x=$x, y=$y, z=$z'); extraSensorData['accelerometer_$channel'] = {'x': x, 'y': y, 'z': z}; break; case MeshCoreConstants.lppBarometer: - pressure = reader.readUInt16LE() / 10.0; + final rawValue = reader.readUInt16LE(); + pressure = rawValue / 10.0; + print(' Barometer (raw): $rawValue'); + print(' Barometer: ${pressure?.toStringAsFixed(1)} hPa'); + break; + + case MeshCoreConstants.lppVoltageSensor: + final rawValue = reader.readUInt16LE(); + final value = rawValue / 100.0; + print(' Voltage (raw): $rawValue'); + print(' Voltage: ${value}V'); + // Treat voltage sensor as battery reading + batteryMilliVolts = value * 1000; + batteryPercentage = _calculateBatteryPercentage(value); + print(' → Battery: ${batteryPercentage?.toStringAsFixed(1)}% (${batteryMilliVolts?.toStringAsFixed(0)}mV)'); break; case MeshCoreConstants.lppGyrometer: final x = reader.readInt16LE() / 100.0; final y = reader.readInt16LE() / 100.0; final z = reader.readInt16LE() / 100.0; + print(' Gyrometer: x=$x, y=$y, z=$z'); extraSensorData['gyrometer_$channel'] = {'x': x, 'y': y, 'z': z}; break; case MeshCoreConstants.lppGps: - final lat = reader.readInt32LE() / 10000.0; - final lon = reader.readInt32LE() / 10000.0; - final alt = reader.readInt32LE() / 100.0; + final rawLat = reader.readInt32LE(); + final rawLon = reader.readInt32LE(); + final rawAlt = reader.readInt32LE(); + final lat = rawLat / 1000000.0; + final lon = rawLon / 1000000.0; + final alt = rawAlt / 100.0; + print(' GPS Location (raw): lat=$rawLat, lon=$rawLon, alt=$rawAlt'); + print(' GPS Location: ${lat}°, ${lon}°, altitude=${alt}m'); gpsLocation = LatLng(lat, lon); extraSensorData['altitude_$channel'] = alt; break; default: + print(' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes'); // Unknown type, skip remaining to avoid parsing errors reader.skip(reader.remainingBytesCount); break; } } catch (e) { + print(' ❌ Parsing error: $e'); // If we encounter a parsing error, break and return what we have break; } } + print(' Parsed $fieldCount fields'); + print(' ✅ [CayenneLPP] Parsing complete'); + print(' GPS: ${gpsLocation != null ? '${gpsLocation.latitude}°, ${gpsLocation.longitude}°' : 'none'}'); + print(' Battery: ${batteryPercentage != null ? '${batteryPercentage.toStringAsFixed(1)}%' : 'none'}'); + print(' Temperature: ${temperature != null ? '${temperature.toStringAsFixed(1)}°C' : 'none'}'); + return ContactTelemetry( gpsLocation: gpsLocation, batteryPercentage: batteryPercentage, diff --git a/lib/services/meshcore_ble_service.dart b/lib/services/meshcore_ble_service.dart index 939ecd1..41a525a 100644 --- a/lib/services/meshcore_ble_service.dart +++ b/lib/services/meshcore_ble_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import '../models/contact.dart'; import '../models/contact_telemetry.dart'; @@ -36,6 +37,16 @@ class MeshCoreBleService { bool _isConnected = false; bool get isConnected => _isConnected; + // Packet counters + int _rxPacketCount = 0; + int _txPacketCount = 0; + int get rxPacketCount => _rxPacketCount; + int get txPacketCount => _txPacketCount; + + // Activity callbacks (for blinking indicators) + VoidCallback? onRxActivity; + VoidCallback? onTxActivity; + /// Scan for MeshCore devices Stream scanForDevices({Duration timeout = const Duration(seconds: 10)}) async* { try { @@ -225,6 +236,10 @@ class MeshCoreBleService { throw Exception('Characteristic does not support write operations'); } + // Increment TX packet counter and trigger activity indicator + _txPacketCount++; + onTxActivity?.call(); + print('✅ [BLE] Write successful'); } catch (e) { print('❌ [BLE] Write error: $e'); @@ -237,6 +252,17 @@ class MeshCoreBleService { void _onDataReceived(List data) { try { print('📥 [BLE] Received ${data.length} bytes from TX characteristic'); + + // Handle empty data + if (data.isEmpty) { + print(' ⚠️ Empty data received, ignoring'); + return; + } + + // 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)); @@ -257,6 +283,10 @@ class MeshCoreBleService { print(' → Handling EndOfContacts'); _handleEndOfContacts(reader); break; + case MeshCoreConstants.respSent: + print(' → Handling Sent confirmation'); + _handleSentConfirmation(reader); + break; case MeshCoreConstants.respContactMsgRecv: print(' → Handling ContactMessage'); _handleContactMessage(reader); @@ -313,16 +343,41 @@ class MeshCoreBleService { /// Handle Contact response void _handleContact(BufferReader reader) { try { + print(' [Contact] Parsing contact...'); + print(' Remaining bytes: ${reader.remainingBytesCount}'); + final publicKey = reader.readBytes(32); - final type = ContactType.fromValue(reader.readByte()); + 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, @@ -337,9 +392,11 @@ class MeshCoreBleService { lastMod: lastMod, ); + print(' ✅ [Contact] Parsed successfully'); _pendingContacts.add(contact); onContactReceived?.call(contact); } catch (e) { + print(' ❌ [Contact] Parsing error: $e'); onError?.call('Contact parsing error: $e'); } } @@ -350,14 +407,62 @@ class MeshCoreBleService { _pendingContacts.clear(); } + /// Handle Sent confirmation response + void _handleSentConfirmation(BufferReader reader) { + try { + print(' [Sent] Parsing sent confirmation...'); + print(' Remaining bytes: ${reader.remainingBytesCount}'); + + // Sent confirmation format (from protocol): + // - 1 byte: reserved + // - 4 bytes: public key prefix (recipient) + // - 2 bytes: message ID + // - 2 bytes: reserved + + if (reader.remainingBytesCount >= 9) { + final reserved1 = reader.readByte(); + print(' Reserved: $reserved1'); + + final pubKeyPrefix = reader.readBytes(4); + print(' Recipient public key prefix: ${pubKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + + final messageId = reader.readUInt16LE(); + print(' Message ID: $messageId'); + + if (reader.remainingBytesCount >= 2) { + final reserved2 = reader.readUInt16LE(); + print(' Reserved2: $reserved2'); + } + } + + print(' ✅ [Sent] Message sent confirmation'); + } catch (e) { + print(' ❌ [Sent] Parsing error: $e'); + // Don't call onError - sent confirmations are informational + } + } + /// Handle ContactMsgRecv response void _handleContactMessage(BufferReader reader) { try { + print(' [ContactMessage] Parsing contact message...'); + print(' Remaining bytes: ${reader.remainingBytesCount}'); + final pubKeyPrefix = reader.readBytes(6); + print(' Sender public key prefix: ${pubKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + final pathLen = reader.readByte(); - final txtType = MessageTextType.fromValue(reader.readByte()); + print(' Path length: $pathLen'); + + final txtTypeByte = reader.readByte(); + final txtType = MessageTextType.fromValue(txtTypeByte); + print(' Text type byte: $txtTypeByte → Type: $txtType'); + final senderTimestamp = reader.readUInt32LE(); + print(' Sender timestamp: $senderTimestamp (${DateTime.fromMillisecondsSinceEpoch(senderTimestamp * 1000)})'); + final text = reader.readString(); + print(' Text: "$text"'); final message = Message( id: '${DateTime.now().millisecondsSinceEpoch}_${pubKeyPrefix.map((b) => b.toRadixString(16)).join()}', @@ -370,8 +475,10 @@ class MeshCoreBleService { receivedAt: DateTime.now(), ); + print(' ✅ [ContactMessage] Parsed successfully'); onMessageReceived?.call(message); } catch (e) { + print(' ❌ [ContactMessage] Parsing error: $e'); onError?.call('Contact message parsing error: $e'); } } @@ -379,11 +486,24 @@ class MeshCoreBleService { /// Handle ChannelMsgRecv response void _handleChannelMessage(BufferReader reader) { try { + print(' [ChannelMessage] Parsing channel message...'); + print(' Remaining bytes: ${reader.remainingBytesCount}'); + final channelIdx = reader.readInt8(); + print(' Channel index: $channelIdx'); + final pathLen = reader.readByte(); - final txtType = MessageTextType.fromValue(reader.readByte()); + print(' Path length: $pathLen'); + + final txtTypeByte = reader.readByte(); + final txtType = MessageTextType.fromValue(txtTypeByte); + print(' Text type byte: $txtTypeByte → Type: $txtType'); + final senderTimestamp = reader.readUInt32LE(); + print(' Sender timestamp: $senderTimestamp (${DateTime.fromMillisecondsSinceEpoch(senderTimestamp * 1000)})'); + final text = reader.readString(); + print(' Text: "$text"'); final message = Message( id: '${DateTime.now().millisecondsSinceEpoch}_ch$channelIdx', @@ -396,8 +516,10 @@ class MeshCoreBleService { receivedAt: DateTime.now(), ); + print(' ✅ [ChannelMessage] Parsed successfully'); onMessageReceived?.call(message); } catch (e) { + print(' ❌ [ChannelMessage] Parsing error: $e'); onError?.call('Channel message parsing error: $e'); } } @@ -405,12 +527,23 @@ class MeshCoreBleService { /// Handle TelemetryResponse push void _handleTelemetryResponse(BufferReader reader) { try { - reader.readByte(); // reserved - final pubKeyPrefix = reader.readBytes(6); - final lppSensorData = reader.readRemainingBytes(); + print(' [Telemetry] Parsing telemetry response...'); + print(' Remaining bytes: ${reader.remainingBytesCount}'); + final reserved = reader.readByte(); + print(' Reserved byte: $reserved'); + + final pubKeyPrefix = reader.readBytes(6); + print(' Public key prefix: ${pubKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + + final lppSensorData = reader.readRemainingBytes(); + print(' LPP sensor data length: ${lppSensorData.length} bytes'); + print(' LPP sensor data (hex): ${lppSensorData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); + + print(' ✅ [Telemetry] Parsed successfully'); onTelemetryReceived?.call(pubKeyPrefix, lppSensorData); } catch (e) { + print(' ❌ [Telemetry] Parsing error: $e'); onError?.call('Telemetry parsing error: $e'); } } @@ -500,7 +633,7 @@ class MeshCoreBleService { print(' Device type: $deviceType'); print(' TX power: $txPower / $maxTxPower dBm'); print(' Public key prefix: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); - print(' Position: ${advLat / 10000.0}, ${advLon / 10000.0}'); + print(' Position: ${advLat / 1000000.0}, ${advLon / 1000000.0}'); print(' Radio: freq=$radioFreq, bw=$radioBw, sf=$radioSf, cr=$radioCr'); if (reader.hasRemaining) { @@ -655,11 +788,17 @@ class MeshCoreBleService { final writer = BufferWriter(); writer.writeByte(MeshCoreConstants.cmdSendSelfAdvert); writer.writeByte(MeshCoreConstants.selfAdvertFlood); - writer.writeInt32LE((latitude * 10000).round()); - writer.writeInt32LE((longitude * 10000).round()); + writer.writeInt32LE((latitude * 1000000).round()); + writer.writeInt32LE((longitude * 1000000).round()); await _writeData(writer.toBytes()); } + /// Reset packet counters + void resetCounters() { + _rxPacketCount = 0; + _txPacketCount = 0; + } + /// Dispose resources void dispose() { _txSubscription?.cancel(); diff --git a/lib/services/meshcore_constants.dart b/lib/services/meshcore_constants.dart index 7739314..edc24a7 100644 --- a/lib/services/meshcore_constants.dart +++ b/lib/services/meshcore_constants.dart @@ -125,6 +125,7 @@ class MeshCoreConstants { static const int lppHumiditySensor = 104; static const int lppAccelerometer = 113; static const int lppBarometer = 115; + static const int lppVoltageSensor = 116; static const int lppGyrometer = 134; static const int lppGps = 136;