mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-14 09:50:30 +00:00
feat: Implement smart ping with automatic fallback and enhance telemetry handling
This commit is contained in:
@@ -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) {
|
connectionProvider.onTelemetryReceived = (publicKey, lppData) {
|
||||||
|
debugPrint('📊 [AppProvider] Telemetry response (0x8B) received - updating contact');
|
||||||
contactsProvider.updateTelemetry(publicKey, lppData);
|
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
|
// When a contact's routing path is updated in the mesh network
|
||||||
connectionProvider.onPathUpdated = (publicKey) {
|
connectionProvider.onPathUpdated = (publicKey) {
|
||||||
debugPrint('🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...');
|
debugPrint('🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...');
|
||||||
|
|||||||
@@ -9,6 +9,22 @@ import '../services/meshcore_ble_service.dart';
|
|||||||
import '../utils/sar_message_parser.dart';
|
import '../utils/sar_message_parser.dart';
|
||||||
import 'helpers/room_login_manager.dart';
|
import 'helpers/room_login_manager.dart';
|
||||||
import 'helpers/message_delivery_tracker.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
|
/// Connection Provider - manages MeshCore BLE connection
|
||||||
class ConnectionProvider with ChangeNotifier {
|
class ConnectionProvider with ChangeNotifier {
|
||||||
@@ -53,6 +69,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
// Helper instances
|
// Helper instances
|
||||||
final RoomLoginManager _roomLoginManager = RoomLoginManager();
|
final RoomLoginManager _roomLoginManager = RoomLoginManager();
|
||||||
final MessageDeliveryTracker _messageDeliveryTracker = MessageDeliveryTracker();
|
final MessageDeliveryTracker _messageDeliveryTracker = MessageDeliveryTracker();
|
||||||
|
final PingTracker _pingTracker = PingTracker();
|
||||||
|
|
||||||
// Expose room login states
|
// Expose room login states
|
||||||
Map<String, RoomLoginState> get roomLoginStates => _roomLoginManager.roomLoginStates;
|
Map<String, RoomLoginState> get roomLoginStates => _roomLoginManager.roomLoginStates;
|
||||||
@@ -133,6 +150,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
};
|
};
|
||||||
|
|
||||||
_bleService.onTelemetryReceived = (publicKey, lppData) {
|
_bleService.onTelemetryReceived = (publicKey, lppData) {
|
||||||
|
// Mark ping as successful if this was a ping request
|
||||||
|
_pingTracker.markPingSuccessful(publicKey);
|
||||||
onTelemetryReceived?.call(publicKey, lppData);
|
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(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
||||||
print(' Tag: $tag');
|
print(' Tag: $tag');
|
||||||
print(' Response data: ${responseData.length} bytes');
|
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);
|
onBinaryResponse?.call(publicKeyPrefix, tag, responseData);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -411,6 +433,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
connectionState: ConnectionState.disconnected,
|
connectionState: ConnectionState.disconnected,
|
||||||
);
|
);
|
||||||
_roomLoginManager.clearRoomLoginStates(); // Clear login states on disconnect
|
_roomLoginManager.clearRoomLoginStates(); // Clear login states on disconnect
|
||||||
|
_pingTracker.clearAll(); // Clear pending pings on disconnect
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,8 +580,20 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Request telemetry from contact
|
/// 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)
|
/// [zeroHop] - if true, only direct connection (no mesh forwarding)
|
||||||
@Deprecated('Use requestBinary() instead for better functionality')
|
|
||||||
Future<void> requestTelemetry(Uint8List contactPublicKey, {bool zeroHop = false}) async {
|
Future<void> requestTelemetry(Uint8List contactPublicKey, {bool zeroHop = false}) async {
|
||||||
if (!_bleService.isConnected) {
|
if (!_bleService.isConnected) {
|
||||||
_error = 'Not connected to device';
|
_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<PingResult> 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)
|
/// Send binary request to contact (modern replacement for requestTelemetry)
|
||||||
///
|
///
|
||||||
/// Supports multiple request types:
|
/// Supports multiple request types:
|
||||||
|
|||||||
@@ -161,20 +161,38 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Update contact telemetry
|
/// Update contact telemetry
|
||||||
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
|
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
|
// Find contact by public key prefix
|
||||||
final contact = _findContactByPrefix(publicKeyPrefix);
|
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 {
|
try {
|
||||||
// Parse Cayenne LPP data
|
// Parse Cayenne LPP data
|
||||||
final telemetry = CayenneLppParser.parse(lppData);
|
final telemetry = CayenneLppParser.parse(lppData);
|
||||||
|
print(' ✅ Parsed new telemetry');
|
||||||
|
print(' New telemetry timestamp: ${telemetry.timestamp}');
|
||||||
|
|
||||||
// Update contact with new telemetry
|
// Update contact with new telemetry
|
||||||
final updatedContact = contact.copyWith(telemetry: telemetry);
|
final updatedContact = contact.copyWith(telemetry: telemetry);
|
||||||
_contacts[contact.publicKeyHex] = updatedContact;
|
_contacts[contact.publicKeyHex] = updatedContact;
|
||||||
|
print(' ✅ Updated contact in map');
|
||||||
|
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
|
print(' ✅ Persisted contacts to storage');
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
print(' ✅ Notified listeners - UI should update');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
print(' ❌ Failed to parse telemetry: $e');
|
||||||
debugPrint('Failed to parse telemetry: $e');
|
debugPrint('Failed to parse telemetry: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
103
lib/providers/helpers/ping_tracker.dart
Normal file
103
lib/providers/helpers/ping_tracker.dart
Normal file
@@ -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<String, _PingRequest> _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<bool> 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<bool>();
|
||||||
|
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<bool> completer;
|
||||||
|
|
||||||
|
_PingRequest({
|
||||||
|
required this.publicKey,
|
||||||
|
required this.wasDirectAttempt,
|
||||||
|
required this.timer,
|
||||||
|
required this.completer,
|
||||||
|
});
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
@@ -46,6 +47,113 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _advertiseDevice(BuildContext context) async {
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
|
||||||
|
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) {
|
void _showConnectionDialog(BuildContext context) {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
|
||||||
@@ -484,6 +592,19 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
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)
|
// Disconnect button (prominent, icon only)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
|||||||
@@ -162,6 +162,18 @@ class CayenneLppParser {
|
|||||||
print(' Battery: ${batteryPercentage != null ? '${batteryPercentage.toStringAsFixed(1)}%' : 'none'}');
|
print(' Battery: ${batteryPercentage != null ? '${batteryPercentage.toStringAsFixed(1)}%' : 'none'}');
|
||||||
print(' Temperature: ${temperature != null ? '${temperature.toStringAsFixed(1)}°C' : '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(
|
return ContactTelemetry(
|
||||||
gpsLocation: gpsLocation,
|
gpsLocation: gpsLocation,
|
||||||
batteryPercentage: batteryPercentage,
|
batteryPercentage: batteryPercentage,
|
||||||
@@ -169,7 +181,7 @@ class CayenneLppParser {
|
|||||||
temperature: temperature,
|
temperature: temperature,
|
||||||
humidity: humidity,
|
humidity: humidity,
|
||||||
pressure: pressure,
|
pressure: pressure,
|
||||||
timestamp: DateTime.now(),
|
timestamp: parseTimestamp,
|
||||||
extraSensorData: extraSensorData.isNotEmpty ? extraSensorData : null,
|
extraSensorData: extraSensorData.isNotEmpty ? extraSensorData : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,42 @@ class ContactTile extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
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
|
// Battery indicator on the right
|
||||||
if (battery != null) ...[
|
if (battery != null) ...[
|
||||||
Icon(
|
Icon(
|
||||||
@@ -258,15 +294,58 @@ class ContactTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
trailing: null,
|
trailing: null,
|
||||||
onTap: () => _showContactDetails(context, contact),
|
onTap: () => _showContactDetails(context, contact),
|
||||||
onLongPress: () {
|
onLongPress: () async {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Pinging ${contact.displayName} (direct connection)...'),
|
content: Text(
|
||||||
duration: const Duration(seconds: 2),
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user