From 24234e7000d87f311aa322b171b37e7a96fd0ded Mon Sep 17 00:00:00 2001 From: Janez T Date: Wed, 15 Oct 2025 19:37:37 +0200 Subject: [PATCH] feat: Implement smart ping with automatic fallback and enhance telemetry handling --- lib/providers/app_provider.dart | 14 ++- lib/providers/connection_provider.dart | 120 ++++++++++++++++++++++- lib/providers/contacts_provider.dart | 20 +++- lib/providers/helpers/ping_tracker.dart | 103 ++++++++++++++++++++ lib/screens/home_screen.dart | 121 ++++++++++++++++++++++++ lib/services/cayenne_lpp_parser.dart | 14 ++- lib/widgets/contacts/contact_tile.dart | 87 ++++++++++++++++- 7 files changed, 471 insertions(+), 8 deletions(-) create mode 100644 lib/providers/helpers/ping_tracker.dart diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index f26252c..4fc2d91 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -91,11 +91,23 @@ class AppProvider with ChangeNotifier { } }; - // When telemetry is received + // When telemetry is received via PUSH_CODE_TELEMETRY_RESPONSE (0x8B) + // Used by older firmware versions for telemetry responses connectionProvider.onTelemetryReceived = (publicKey, lppData) { + debugPrint('📊 [AppProvider] Telemetry response (0x8B) received - updating contact'); contactsProvider.updateTelemetry(publicKey, lppData); }; + // When binary response is received via PUSH_CODE_BINARY_RESPONSE (0x8C) + // Used by newer firmware versions for telemetry and other binary data + // BOTH callbacks (0x8B and 0x8C) must be handled for device compatibility + connectionProvider.onBinaryResponse = (publicKeyPrefix, tag, responseData) { + debugPrint('📊 [AppProvider] Binary response (0x8C) received - updating contact telemetry'); + // Binary response tag 0 = telemetry data (Cayenne LPP format) + // Other tags may be used for different data types in the future + contactsProvider.updateTelemetry(publicKeyPrefix, responseData); + }; + // When a contact's routing path is updated in the mesh network connectionProvider.onPathUpdated = (publicKey) { debugPrint('🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...'); diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index ebebdc4..52933a8 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -9,6 +9,22 @@ import '../services/meshcore_ble_service.dart'; import '../utils/sar_message_parser.dart'; import 'helpers/room_login_manager.dart'; import 'helpers/message_delivery_tracker.dart'; +import 'helpers/ping_tracker.dart'; + +/// Result of a ping (telemetry request) operation +class PingResult { + final bool success; + final bool usedFlooding; + final bool timedOut; + final bool retriedWithFlooding; + + const PingResult({ + required this.success, + required this.usedFlooding, + required this.timedOut, + this.retriedWithFlooding = false, + }); +} /// Connection Provider - manages MeshCore BLE connection class ConnectionProvider with ChangeNotifier { @@ -53,6 +69,7 @@ class ConnectionProvider with ChangeNotifier { // Helper instances final RoomLoginManager _roomLoginManager = RoomLoginManager(); final MessageDeliveryTracker _messageDeliveryTracker = MessageDeliveryTracker(); + final PingTracker _pingTracker = PingTracker(); // Expose room login states Map get roomLoginStates => _roomLoginManager.roomLoginStates; @@ -133,6 +150,8 @@ class ConnectionProvider with ChangeNotifier { }; _bleService.onTelemetryReceived = (publicKey, lppData) { + // Mark ping as successful if this was a ping request + _pingTracker.markPingSuccessful(publicKey); onTelemetryReceived?.call(publicKey, lppData); }; @@ -141,6 +160,9 @@ class ConnectionProvider with ChangeNotifier { print(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); print(' Tag: $tag'); print(' Response data: ${responseData.length} bytes'); + // Mark ping as successful if this was a ping request + // Binary responses can also be telemetry responses (newer firmware) + _pingTracker.markPingSuccessful(publicKeyPrefix); onBinaryResponse?.call(publicKeyPrefix, tag, responseData); }; @@ -411,6 +433,7 @@ class ConnectionProvider with ChangeNotifier { connectionState: ConnectionState.disconnected, ); _roomLoginManager.clearRoomLoginStates(); // Clear login states on disconnect + _pingTracker.clearAll(); // Clear pending pings on disconnect notifyListeners(); } @@ -557,8 +580,20 @@ class ConnectionProvider with ChangeNotifier { } /// Request telemetry from contact + /// + /// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39). + /// Depending on device firmware version, the response will be either: + /// - PUSH_CODE_TELEMETRY_RESPONSE (0x8B) - older firmware + /// - PUSH_CODE_BINARY_RESPONSE (0x8C) - newer firmware + /// + /// Both response types are handled via callbacks: + /// - onTelemetryReceived (for 0x8B) + /// - onBinaryResponse (for 0x8C) + /// + /// The app properly handles BOTH response types, so this method is NOT + /// deprecated and should continue to be used for telemetry requests. + /// /// [zeroHop] - if true, only direct connection (no mesh forwarding) - @Deprecated('Use requestBinary() instead for better functionality') Future requestTelemetry(Uint8List contactPublicKey, {bool zeroHop = false}) async { if (!_bleService.isConnected) { _error = 'Not connected to device'; @@ -574,6 +609,89 @@ class ConnectionProvider with ChangeNotifier { } } + /// Smart ping with automatic fallback to flooding + /// + /// Sends a telemetry request (ping) to a contact, and if no response is + /// received within timeout, automatically retries with flooding mode. + /// + /// Returns a PingResult with information about the response. + /// + /// [contact] - the contact to ping (used to determine if path exists) + /// [onRetryWithFlooding] - optional callback when fallback to flooding occurs + Future smartPing({ + required Uint8List contactPublicKey, + required bool hasPath, + Function()? onRetryWithFlooding, + }) async { + if (!_bleService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return PingResult(success: false, usedFlooding: false, timedOut: true); + } + + // First attempt: Use zeroHop (direct) if we have a path, otherwise use flooding + final bool firstAttemptDirect = hasPath; + + try { + // Track the ping request + final pingFuture = _pingTracker.trackPing( + publicKey: contactPublicKey, + wasDirectAttempt: firstAttemptDirect, + ); + + // Send the ping + await _bleService.requestTelemetry(contactPublicKey, zeroHop: true); + + // Wait for response or timeout + final bool gotResponse = await pingFuture; + + if (gotResponse) { + // Success on first attempt + return PingResult( + success: true, + usedFlooding: !firstAttemptDirect, + timedOut: false, + ); + } + + // First attempt timed out - retry with flooding if first was direct + if (firstAttemptDirect) { + print('⚠️ [Provider] Ping timeout on direct attempt, retrying with flooding...'); + onRetryWithFlooding?.call(); + + // Track the retry + final retryFuture = _pingTracker.trackPing( + publicKey: contactPublicKey, + wasDirectAttempt: false, + ); + + // Retry with flooding (zeroHop=true acts as broadcast to neighbors) + await _bleService.requestTelemetry(contactPublicKey, zeroHop: true); + + // Wait for response or timeout + final bool gotRetryResponse = await retryFuture; + + return PingResult( + success: gotRetryResponse, + usedFlooding: true, + timedOut: !gotRetryResponse, + retriedWithFlooding: true, + ); + } + + // First attempt was already flooding and it timed out + return PingResult( + success: false, + usedFlooding: true, + timedOut: true, + ); + } catch (e) { + _error = 'Failed to ping contact: $e'; + notifyListeners(); + return PingResult(success: false, usedFlooding: false, timedOut: true); + } + } + /// Send binary request to contact (modern replacement for requestTelemetry) /// /// Supports multiple request types: diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index c2408d1..05ddcc8 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -161,20 +161,38 @@ class ContactsProvider with ChangeNotifier { /// Update contact telemetry void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) { + print('📊 [ContactsProvider] updateTelemetry() called'); + print(' Public key prefix (hex): ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + print(' LPP data size: ${lppData.length} bytes'); + // Find contact by public key prefix final contact = _findContactByPrefix(publicKeyPrefix); - if (contact == null) return; + if (contact == null) { + print(' ❌ Contact not found for this prefix'); + return; + } + + print(' ✅ Found contact: ${contact.advName}'); + print(' Old telemetry timestamp: ${contact.telemetry?.timestamp}'); try { // Parse Cayenne LPP data final telemetry = CayenneLppParser.parse(lppData); + print(' ✅ Parsed new telemetry'); + print(' New telemetry timestamp: ${telemetry.timestamp}'); // Update contact with new telemetry final updatedContact = contact.copyWith(telemetry: telemetry); _contacts[contact.publicKeyHex] = updatedContact; + print(' ✅ Updated contact in map'); + _persistContacts(); + print(' ✅ Persisted contacts to storage'); + notifyListeners(); + print(' ✅ Notified listeners - UI should update'); } catch (e) { + print(' ❌ Failed to parse telemetry: $e'); debugPrint('Failed to parse telemetry: $e'); } } diff --git a/lib/providers/helpers/ping_tracker.dart b/lib/providers/helpers/ping_tracker.dart new file mode 100644 index 0000000..0bf51e5 --- /dev/null +++ b/lib/providers/helpers/ping_tracker.dart @@ -0,0 +1,103 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; + +/// Helper class to track pending ping (telemetry) requests +/// and implement automatic fallback to flooding if no response received +class PingTracker { + // Map of public key hex string to ping request state + final Map _pendingPings = {}; + + // Timeout duration for ping responses (seconds) + static const int _pingTimeoutSeconds = 5; + + /// Track a new ping request + /// Returns a Future that completes when either: + /// - A response is received (completes with true) + /// - Timeout occurs (completes with false) + Future trackPing({ + required Uint8List publicKey, + required bool wasDirectAttempt, + }) { + final String keyHex = _publicKeyToHex(publicKey); + + // Cancel any existing pending ping for this contact + _pendingPings[keyHex]?.cancel(); + + // Create new ping request tracker + final completer = Completer(); + final timer = Timer(const Duration(seconds: _pingTimeoutSeconds), () { + // Timeout occurred - mark as failed + _pendingPings.remove(keyHex); + if (!completer.isCompleted) { + completer.complete(false); + } + }); + + _pendingPings[keyHex] = _PingRequest( + publicKey: publicKey, + wasDirectAttempt: wasDirectAttempt, + timer: timer, + completer: completer, + ); + + return completer.future; + } + + /// Mark a ping as successful (response received) + /// Should be called when telemetry response arrives + void markPingSuccessful(Uint8List publicKey) { + final String keyHex = _publicKeyToHex(publicKey); + final request = _pendingPings.remove(keyHex); + + if (request != null) { + request.cancel(); + if (!request.completer.isCompleted) { + request.completer.complete(true); + } + } + } + + /// Check if there's a pending ping for this contact + bool hasPendingPing(Uint8List publicKey) { + final String keyHex = _publicKeyToHex(publicKey); + return _pendingPings.containsKey(keyHex); + } + + /// Get pending ping info (was it a direct attempt?) + bool? wasPingDirect(Uint8List publicKey) { + final String keyHex = _publicKeyToHex(publicKey); + return _pendingPings[keyHex]?.wasDirectAttempt; + } + + /// Clear all pending pings (useful on disconnect) + void clearAll() { + for (final request in _pendingPings.values) { + request.cancel(); + } + _pendingPings.clear(); + } + + /// Convert public key to hex string for map key + String _publicKeyToHex(Uint8List publicKey) { + return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); + } +} + +/// Internal class to track a single ping request +class _PingRequest { + final Uint8List publicKey; + final bool wasDirectAttempt; + final Timer timer; + final Completer completer; + + _PingRequest({ + required this.publicKey, + required this.wasDirectAttempt, + required this.timer, + required this.completer, + }); + + void cancel() { + timer.cancel(); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 7f422b8..7551b4d 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:geolocator/geolocator.dart'; import '../providers/connection_provider.dart'; import '../providers/app_provider.dart'; import '../theme/app_theme.dart'; @@ -46,6 +47,113 @@ class _HomeScreenState extends State with SingleTickerProviderStateM super.dispose(); } + Future _advertiseDevice(BuildContext context) async { + final connectionProvider = context.read(); + + if (!connectionProvider.deviceInfo.isConnected) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Device not connected')), + ); + } + return; + } + + try { + // Check if location services are enabled + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Location services are disabled. Please enable them in Settings.'), + duration: Duration(seconds: 3), + ), + ); + } + return; + } + + // Check location permissions + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Location permission denied'), + duration: Duration(seconds: 2), + ), + ); + } + return; + } + } + + if (permission == LocationPermission.deniedForever) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Location permission permanently denied. Please enable in Settings.'), + duration: Duration(seconds: 3), + ), + ); + } + return; + } + + // Get current GPS position + Position? position; + try { + position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.best, + distanceFilter: 0, + ), + ).timeout(const Duration(seconds: 5)); + } catch (e) { + print('❌ Failed to get GPS position: $e'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to get GPS location')), + ); + } + return; + } + + // Update lat/lon on device + await connectionProvider.setAdvertLatLon( + latitude: position.latitude, + longitude: position.longitude, + ); + + // Small delay to ensure the lat/lon is set + await Future.delayed(const Duration(milliseconds: 100)); + + // Send flood advertisement + await connectionProvider.sendSelfAdvert(floodMode: true); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Advertised at ${position.latitude.toStringAsFixed(6)}, ${position.longitude.toStringAsFixed(6)}', + ), + duration: const Duration(seconds: 2), + ), + ); + } + } catch (e) { + print('❌ Failed to advertise device: $e'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to advertise: $e')), + ); + } + } + } + void _showConnectionDialog(BuildContext context) { final connectionProvider = context.read(); @@ -484,6 +592,19 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ), ), const SizedBox(width: 8), + // Advertise button (broadcast location) + FilledButton( + onPressed: () => _advertiseDevice(context), + style: FilledButton.styleFrom( + backgroundColor: Colors.blue.shade700, + foregroundColor: Colors.white, + padding: const EdgeInsets.all(10), + minimumSize: const Size(40, 40), + shape: const CircleBorder(), + ), + child: const Icon(Icons.campaign, size: 20), + ), + const SizedBox(width: 8), // Disconnect button (prominent, icon only) FilledButton( onPressed: () async { diff --git a/lib/services/cayenne_lpp_parser.dart b/lib/services/cayenne_lpp_parser.dart index d568ecf..a489bcf 100644 --- a/lib/services/cayenne_lpp_parser.dart +++ b/lib/services/cayenne_lpp_parser.dart @@ -162,6 +162,18 @@ class CayenneLppParser { print(' Battery: ${batteryPercentage != null ? '${batteryPercentage.toStringAsFixed(1)}%' : 'none'}'); print(' Temperature: ${temperature != null ? '${temperature.toStringAsFixed(1)}°C' : 'none'}'); + // IMPORTANT: Cayenne LPP format does NOT include a timestamp field. + // We use DateTime.now() as the timestamp, which represents when the data + // was RECEIVED/PARSED by the app, NOT when it was collected by the device. + // + // This means: + // - If the device sends cached/old telemetry data, the timestamp will still + // show as "recent" (a few seconds ago) because it was just received + // - The actual age of the telemetry data cannot be determined from the LPP format + // - Devices may cache telemetry for hours and send it later when requested + final parseTimestamp = DateTime.now(); + print(' Timestamp: $parseTimestamp (parse time, NOT device collection time)'); + return ContactTelemetry( gpsLocation: gpsLocation, batteryPercentage: batteryPercentage, @@ -169,7 +181,7 @@ class CayenneLppParser { temperature: temperature, humidity: humidity, pressure: pressure, - timestamp: DateTime.now(), + timestamp: parseTimestamp, extraSensorData: extraSensorData.isNotEmpty ? extraSensorData : null, ); } diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 91ec3a4..6e10822 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -95,6 +95,42 @@ class ContactTile extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ), + // Connection type indicator (direct/flood) + if (contact.type == ContactType.chat) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: contact.hasPath + ? Colors.green.withOpacity(0.15) + : Colors.orange.withOpacity(0.15), + borderRadius: BorderRadius.circular(3), + border: Border.all( + color: contact.hasPath ? Colors.green : Colors.orange, + width: 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + contact.hasPath ? Icons.route : Icons.waves, + size: 10, + color: contact.hasPath ? Colors.green : Colors.orange, + ), + const SizedBox(width: 2), + Text( + contact.hasPath ? 'Direct' : 'Flood', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: contact.hasPath ? Colors.green : Colors.orange, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + ], // Battery indicator on the right if (battery != null) ...[ Icon( @@ -258,15 +294,58 @@ class ContactTile extends StatelessWidget { ), trailing: null, onTap: () => _showContactDetails(context, contact), - onLongPress: () { + onLongPress: () async { final connectionProvider = context.read(); - connectionProvider.requestTelemetry(contact.publicKey, zeroHop: true); + + // Determine if we should use flooding (no path) or direct (has path) + final hasPath = contact.hasPath; + + // Show initial notification reflecting the method being used ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Pinging ${contact.displayName} (direct connection)...'), - duration: const Duration(seconds: 2), + content: Text( + hasPath + ? 'Pinging ${contact.displayName} (direct via path)...' + : 'Pinging ${contact.displayName} (flooding - no path)...', + ), + duration: const Duration(seconds: 6), ), ); + + // Use smart ping with automatic fallback + final result = await connectionProvider.smartPing( + contactPublicKey: contact.publicKey, + hasPath: hasPath, + onRetryWithFlooding: () { + // Called when retrying with flooding after direct timeout + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Direct ping timeout - retrying ${contact.displayName} with flooding...', + ), + duration: const Duration(seconds: 3), + backgroundColor: Colors.orange, + ), + ); + } + }, + ); + + // Show final result + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.success + ? 'Ping successful to ${contact.displayName}${result.retriedWithFlooding ? ' (via flooding fallback)' : ''}' + : 'Ping failed to ${contact.displayName} - no response received', + ), + duration: const Duration(seconds: 2), + backgroundColor: result.success ? Colors.green : Colors.red, + ), + ); + } }, ), );