Add packet filter stats and hex

This commit is contained in:
Janez T
2026-03-12 20:54:04 +01:00
parent c36d63a0ad
commit 53daf2c9a7
15 changed files with 2265 additions and 56 deletions

View File

@@ -0,0 +1,31 @@
class SavedContactGroup {
final String id;
final String sectionKey;
final String label;
final String query;
final DateTime createdAt;
const SavedContactGroup({
required this.id,
required this.sectionKey,
required this.label,
required this.query,
required this.createdAt,
});
SavedContactGroup copyWith({
String? id,
String? sectionKey,
String? label,
String? query,
DateTime? createdAt,
}) {
return SavedContactGroup(
id: id ?? this.id,
sectionKey: sectionKey ?? this.sectionKey,
label: label ?? this.label,
query: query ?? this.query,
createdAt: createdAt ?? this.createdAt,
);
}
}

View File

@@ -1,7 +1,10 @@
enum PathRecordSource { learned, observed }
class PathRecord {
final List<int> pathBytes;
final int hopCount;
final int hashSize;
final PathRecordSource source;
final int successCount;
final int failureCount;
final int lastRoundTripTimeMs;
@@ -11,6 +14,7 @@ class PathRecord {
required this.pathBytes,
required this.hopCount,
required this.hashSize,
required this.source,
required this.successCount,
required this.failureCount,
required this.lastRoundTripTimeMs,
@@ -27,6 +31,7 @@ class PathRecord {
List<int>? pathBytes,
int? hopCount,
int? hashSize,
PathRecordSource? source,
int? successCount,
int? failureCount,
int? lastRoundTripTimeMs,
@@ -36,6 +41,7 @@ class PathRecord {
pathBytes: pathBytes ?? this.pathBytes,
hopCount: hopCount ?? this.hopCount,
hashSize: hashSize ?? this.hashSize,
source: source ?? this.source,
successCount: successCount ?? this.successCount,
failureCount: failureCount ?? this.failureCount,
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
@@ -48,6 +54,7 @@ class PathRecord {
'path_bytes': pathBytes,
'hop_count': hopCount,
'hash_size': hashSize,
'source': source.name,
'success_count': successCount,
'failure_count': failureCount,
'last_round_trip_time_ms': lastRoundTripTimeMs,
@@ -62,6 +69,10 @@ class PathRecord {
.toList(),
hopCount: json['hop_count'] as int? ?? 0,
hashSize: json['hash_size'] as int? ?? 1,
source: PathRecordSource.values.firstWhere(
(value) => value.name == (json['source'] as String? ?? 'learned'),
orElse: () => PathRecordSource.learned,
),
successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0,
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
@@ -163,6 +174,10 @@ class ContactPathHistory {
};
}
List<PathRecord> get observedPaths => directPaths
.where((record) => record.source == PathRecordSource.observed)
.toList();
factory ContactPathHistory.fromJson(
String contactPublicKeyHex,
Map<String, dynamic> json,

View File

@@ -2,6 +2,7 @@ import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
import '../models/message_contact_location.dart';
import '../services/cayenne_lpp_parser.dart';
import '../services/contact_storage_service.dart';
@@ -58,6 +59,7 @@ class _RetainedRoute {
class ContactsProvider with ChangeNotifier {
static const double _firstHopFallbackOffsetMeters = 100.0;
final Map<String, Contact> _contacts = {};
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
final Map<String, PendingAdvert> _pendingAdverts = {};
final ContactStorageService _storageService = ContactStorageService();
bool _isInitialized = false;
@@ -79,6 +81,7 @@ class ContactsProvider with ChangeNotifier {
'📦 [ContactsProvider] Early loading persisted contacts (no filtering)...',
);
final storedContacts = await _storageService.loadContacts();
final storedGroups = await _storageService.loadContactGroups();
// Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey =
@@ -92,8 +95,11 @@ class ContactsProvider with ChangeNotifier {
}
_isInitialized = true;
_savedContactGroups
..clear()
..addAll(storedGroups);
debugPrint(
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts',
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
);
// Ensure public channel exists after loading
@@ -123,6 +129,7 @@ class ContactsProvider with ChangeNotifier {
final storedContacts = await _storageService.loadContacts(
excludePublicKey: devicePublicKey,
);
final storedGroups = await _storageService.loadContactGroups();
// Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey =
@@ -136,8 +143,11 @@ class ContactsProvider with ChangeNotifier {
}
_isInitialized = true;
_savedContactGroups
..clear()
..addAll(storedGroups);
debugPrint(
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts',
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
);
// Ensure public channel exists after loading
@@ -208,10 +218,97 @@ class ContactsProvider with ChangeNotifier {
}
List<Contact> get contacts => _contacts.values.toList();
List<SavedContactGroup> get savedContactGroups =>
List<SavedContactGroup>.from(_savedContactGroups)
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
List<PendingAdvert> get pendingAdverts =>
_pendingAdverts.values.toList()
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
List<SavedContactGroup> savedGroupsForSection(String sectionKey) {
return savedContactGroups
.where((group) => group.sectionKey == sectionKey)
.toList();
}
bool hasSavedGroupForFilter(String sectionKey, String query) {
final normalizedQuery = _normalizeGroupQuery(query);
if (normalizedQuery.isEmpty) {
return false;
}
return _savedContactGroups.any(
(group) =>
group.sectionKey == sectionKey &&
_normalizeGroupQuery(group.query) == normalizedQuery,
);
}
Future<void> addSavedGroupForFilter(
String sectionKey,
String query, {
String? label,
}) async {
final normalizedQuery = _normalizeGroupQuery(query);
if (normalizedQuery.isEmpty ||
hasSavedGroupForFilter(sectionKey, normalizedQuery)) {
return;
}
_savedContactGroups.add(
SavedContactGroup(
id: '${sectionKey}_${DateTime.now().microsecondsSinceEpoch}',
sectionKey: sectionKey,
label: (label ?? query).trim(),
query: query.trim(),
createdAt: DateTime.now(),
),
);
await _persistSavedGroups();
notifyListeners();
}
Future<void> removeSavedGroupById(String id) async {
final beforeCount = _savedContactGroups.length;
_savedContactGroups.removeWhere((group) => group.id == id);
if (_savedContactGroups.length == beforeCount) {
return;
}
await _persistSavedGroups();
notifyListeners();
}
Future<void> removeSavedGroupForFilter(
String sectionKey,
String query,
) async {
final normalizedQuery = _normalizeGroupQuery(query);
final beforeCount = _savedContactGroups.length;
_savedContactGroups.removeWhere(
(group) =>
group.sectionKey == sectionKey &&
_normalizeGroupQuery(group.query) == normalizedQuery,
);
if (_savedContactGroups.length == beforeCount) {
return;
}
await _persistSavedGroups();
notifyListeners();
}
Future<void> _persistSavedGroups() async {
try {
await _storageService.saveContactGroups(_savedContactGroups);
} catch (e) {
debugPrint('❌ [ContactsProvider] Error persisting contact groups: $e');
}
}
String _normalizeGroupQuery(String query) => query.trim().toLowerCase();
List<Contact> get chatContacts =>
contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen);

View File

@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import '../l10n/app_localizations.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
import '../providers/contacts_provider.dart';
import '../providers/app_provider.dart';
import '../providers/connection_provider.dart';
@@ -203,6 +204,80 @@ class _ContactsTabState extends State<ContactsTab> {
}).toList();
}
bool _contactMatchesFilter(Contact contact, String query) {
final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) {
return true;
}
final name = contact.displayName.toLowerCase();
final advertisedName = contact.advName.toLowerCase();
return name.contains(normalizedQuery) ||
advertisedName.contains(normalizedQuery);
}
List<_RenderedSavedGroup> _buildSavedGroupsForSection(
ContactsProvider contactsProvider,
List<Contact> contacts,
ContactSection section,
) {
return contactsProvider
.savedGroupsForSection(section.name)
.map((group) {
final matches = contacts
.where((contact) => _contactMatchesFilter(contact, group.query))
.toList();
return _RenderedSavedGroup(group: group, contacts: matches);
})
.where((group) => group.contacts.isNotEmpty)
.toList()
..sort(
(a, b) => b.contacts.first.lastSeenTime.compareTo(
a.contacts.first.lastSeenTime,
),
);
}
Future<void> _toggleSavedGroupForSection(
BuildContext context,
ContactsProvider contactsProvider,
ContactSection section,
) async {
final filter = (_sectionFilters[section] ?? '').trim();
if (filter.isEmpty) {
return;
}
final alreadySaved = contactsProvider.hasSavedGroupForFilter(
section.name,
filter,
);
if (alreadySaved) {
await contactsProvider.removeSavedGroupForFilter(section.name, filter);
} else {
await contactsProvider.addSavedGroupForFilter(
section.name,
filter,
label: filter,
);
}
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
alreadySaved
? 'Removed saved group "$filter"'
: 'Saved group "$filter"',
),
),
);
}
List<Contact> _sortContacts(List<Contact> contacts, ContactSection section) {
final sorted = List<Contact>.from(contacts);
if (section == ContactSection.channels) {
@@ -319,18 +394,38 @@ class _ContactsTabState extends State<ContactsTab> {
allChatContacts,
ContactSection.teamMembers,
);
final savedTeamGroups = _buildSavedGroupsForSection(
contactsProvider,
allChatContacts,
ContactSection.teamMembers,
);
final repeaters = _filterContactsForSection(
allRepeaters,
ContactSection.repeaters,
);
final savedRepeaterGroups = _buildSavedGroupsForSection(
contactsProvider,
allRepeaters,
ContactSection.repeaters,
);
final rooms = _filterContactsForSection(
allRooms,
ContactSection.rooms,
);
final savedRoomGroups = _buildSavedGroupsForSection(
contactsProvider,
allRooms,
ContactSection.rooms,
);
final filteredChannels = _filterContactsForSection(
allChannels,
ContactSection.channels,
);
final savedChannelGroups = _buildSavedGroupsForSection(
contactsProvider,
allChannels,
ContactSection.channels,
);
final pendingAdverts = contactsProvider.pendingAdverts;
_schedulePendingAdvertResolution(pendingAdverts, connectionProvider);
@@ -385,11 +480,22 @@ class _ContactsTabState extends State<ContactsTab> {
ContactSection.teamMembers,
),
),
_buildSectionFilterField(context, ContactSection.teamMembers),
_buildSectionFilterField(
context,
ContactSection.teamMembers,
contactsProvider,
),
if (chatContacts.isEmpty)
_buildEmptyFilterState(context)
else
..._buildContactSectionItems(chatContacts),
else ...[
..._buildSavedGroupCards(
savedTeamGroups,
ContactSection.teamMembers,
),
..._buildContactSectionItems(
_excludeGroupedContacts(chatContacts, savedTeamGroups),
),
],
const Divider(height: 32),
],
@@ -401,11 +507,22 @@ class _ContactsTabState extends State<ContactsTab> {
icon: Icons.router,
trailing: _buildSortMenu(context, ContactSection.repeaters),
),
_buildSectionFilterField(context, ContactSection.repeaters),
_buildSectionFilterField(
context,
ContactSection.repeaters,
contactsProvider,
),
if (repeaters.isEmpty)
_buildEmptyFilterState(context)
else
..._buildContactSectionItems(repeaters),
else ...[
..._buildSavedGroupCards(
savedRepeaterGroups,
ContactSection.repeaters,
),
..._buildContactSectionItems(
_excludeGroupedContacts(repeaters, savedRepeaterGroups),
),
],
const Divider(height: 32),
],
@@ -417,11 +534,22 @@ class _ContactsTabState extends State<ContactsTab> {
icon: Icons.tag,
trailing: _buildSortMenu(context, ContactSection.rooms),
),
_buildSectionFilterField(context, ContactSection.rooms),
_buildSectionFilterField(
context,
ContactSection.rooms,
contactsProvider,
),
if (rooms.isEmpty)
_buildEmptyFilterState(context)
else
..._buildContactSectionItems(rooms),
else ...[
..._buildSavedGroupCards(
savedRoomGroups,
ContactSection.rooms,
),
..._buildContactSectionItems(
_excludeGroupedContacts(rooms, savedRoomGroups),
),
],
const Divider(height: 32),
],
@@ -452,11 +580,22 @@ class _ContactsTabState extends State<ContactsTab> {
count: filteredChannels.length,
icon: Icons.broadcast_on_personal,
),
_buildSectionFilterField(context, ContactSection.channels),
_buildSectionFilterField(
context,
ContactSection.channels,
contactsProvider,
),
if (allChannels.isNotEmpty && filteredChannels.isEmpty)
_buildEmptyFilterState(context),
..._buildSavedGroupCards(
savedChannelGroups,
ContactSection.channels,
),
if (filteredChannels.isNotEmpty) ...[
...filteredChannels.map(
..._excludeGroupedContacts(
filteredChannels,
savedChannelGroups,
).map(
(channel) => _ChannelActivityCard(
channel: channel,
messagesProvider: messagesProvider,
@@ -520,14 +659,58 @@ class _ContactsTabState extends State<ContactsTab> {
}).toList();
}
List<Contact> _excludeGroupedContacts(
List<Contact> contacts,
List<_RenderedSavedGroup> savedGroups,
) {
final groupedKeys = savedGroups
.expand(
(group) => group.contacts.map((contact) => contact.publicKeyHex),
)
.toSet();
return contacts
.where((contact) => !groupedKeys.contains(contact.publicKeyHex))
.toList();
}
List<Widget> _buildSavedGroupCards(
List<_RenderedSavedGroup> groups,
ContactSection section,
) {
return groups
.map(
(group) => _InferredContactGroupCard(
label: group.group.label,
contacts: group.contacts,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
onDelete: () => context
.read<ContactsProvider>()
.removeSavedGroupById(group.group.id),
kindLabel: 'Saved filter',
),
)
.toList();
}
Widget _buildSectionFilterField(
BuildContext context,
ContactSection section,
ContactsProvider contactsProvider,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final controller = _filterControllers[section]!;
final hasFilter = (_sectionFilters[section] ?? '').isNotEmpty;
final isSavedFilter = hasFilter
? contactsProvider.hasSavedGroupForFilter(
section.name,
_sectionFilters[section] ?? '',
)
: false;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
@@ -600,7 +783,38 @@ class _ContactsTabState extends State<ContactsTab> {
),
),
),
if (hasFilter)
if (hasFilter) ...[
Padding(
padding: const EdgeInsets.only(right: 4),
child: Material(
color:
(isSavedFilter
? colorScheme.error
: colorScheme.primary)
.withValues(alpha: 0.10),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => _toggleSavedGroupForSection(
context,
contactsProvider,
section,
),
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
isSavedFilter
? Icons.delete_outline_rounded
: Icons.bookmark_add_outlined,
size: 16,
color: isSavedFilter
? colorScheme.error
: colorScheme.primary,
),
),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 6),
child: Material(
@@ -624,8 +838,8 @@ class _ContactsTabState extends State<ContactsTab> {
),
),
),
)
else
),
] else
const SizedBox(width: 12),
],
),
@@ -751,6 +965,13 @@ class _PendingAdvertTile extends StatelessWidget {
}
}
class _RenderedSavedGroup {
final SavedContactGroup group;
final List<Contact> contacts;
const _RenderedSavedGroup({required this.group, required this.contacts});
}
class _SectionHeader extends StatelessWidget {
final String title;
final int count;
@@ -800,20 +1021,24 @@ class _SectionHeader extends StatelessWidget {
class _InferredContactGroupCard extends StatelessWidget {
final String label;
final List<Contact> contacts;
final String? kindLabel;
final Position? currentPosition;
final double Function(double, double, double, double) calculateDistance;
final String Function(double) formatDistance;
final VoidCallback? onNavigateToMap;
final VoidCallback? onNavigateToMessages;
final VoidCallback? onDelete;
const _InferredContactGroupCard({
required this.label,
required this.contacts,
this.kindLabel,
required this.currentPosition,
required this.calculateDistance,
required this.formatDistance,
required this.onNavigateToMap,
required this.onNavigateToMessages,
this.onDelete,
});
@override
@@ -843,11 +1068,24 @@ class _InferredContactGroupCard extends StatelessWidget {
title: Row(
children: [
Expanded(
child: Text(
label,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
if (kindLabel case final value?)
Text(
value,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(width: 8),
@@ -862,6 +1100,18 @@ class _InferredContactGroupCard extends StatelessWidget {
style: Theme.of(context).textTheme.labelSmall,
),
),
if (onDelete != null) ...[
const SizedBox(width: 4),
IconButton(
tooltip: 'Delete group',
onPressed: onDelete,
icon: Icon(
Icons.delete_outline_rounded,
size: 18,
color: colorScheme.error,
),
),
],
],
),
children: [

View File

@@ -18,6 +18,7 @@ import 'repeaters_map_screen.dart';
import 'settings_screen.dart';
import 'device_config_screen.dart';
import 'packet_log_screen.dart';
import 'live_traffic_screen.dart';
import 'spectrum_scan_screen.dart';
import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart';
@@ -218,6 +219,10 @@ class _HomeScreenState extends State<HomeScreen>
);
}
void _openLiveTraffic(ConnectionProvider provider) {
openLiveTrafficScreen(context, provider);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleState = state;
@@ -647,6 +652,41 @@ class _HomeScreenState extends State<HomeScreen>
);
}
items.add(
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.radar_outlined),
SizedBox(width: 8),
Text('Live Traffic'),
],
),
onTap: () {
final navigator = Navigator.of(context);
final provider = context.read<ConnectionProvider>();
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (_) => LiveTrafficScreen.fromProvider(
provider,
openPacketLogs: () {
navigator.push(
MaterialPageRoute(
builder: (_) => PacketLogScreen(
bleService: provider.bleService,
),
),
);
},
),
),
);
});
},
),
);
items.add(
PopupMenuItem(
child: const Row(
@@ -1056,6 +1096,7 @@ class _HomeScreenState extends State<HomeScreen>
SizedBox(width: isTight ? 8 : 12),
if (_showRxTxIndicators)
GestureDetector(
onTap: () => _openLiveTraffic(provider),
onLongPress: () {
Navigator.push(
context,

File diff suppressed because it is too large Load Diff

View File

@@ -2,12 +2,14 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
import '../utils/key_comparison.dart';
import 'package:latlong2/latlong.dart';
/// Service for persisting contacts to local storage
class ContactStorageService {
static const String _contactsKey = 'stored_contacts';
static const String _contactGroupsKey = 'stored_contact_groups';
static const int _maxStoredContacts = 500; // Store up to 500 contacts
/// Save contacts to persistent storage
@@ -90,6 +92,40 @@ class ContactStorageService {
}
}
Future<void> saveContactGroups(List<SavedContactGroup> groups) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = jsonEncode(
groups.map((group) => _contactGroupToJson(group)).toList(),
);
await prefs.setString(_contactGroupsKey, jsonString);
debugPrint(
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error saving contact groups: $e');
}
}
Future<List<SavedContactGroup>> loadContactGroups() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactGroupsKey);
if (jsonString == null || jsonString.isEmpty) {
return [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList
.map((json) => _contactGroupFromJson(json as Map<String, dynamic>))
.whereType<SavedContactGroup>()
.toList();
} catch (e) {
debugPrint('❌ [ContactStorage] Error loading contact groups: $e');
return [];
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
try {
@@ -203,4 +239,31 @@ class ContactStorageService {
return null;
}
}
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
return {
'id': group.id,
'sectionKey': group.sectionKey,
'label': group.label,
'query': group.query,
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
};
}
SavedContactGroup? _contactGroupFromJson(Map<String, dynamic> json) {
try {
return SavedContactGroup(
id: json['id'] as String,
sectionKey: json['sectionKey'] as String,
label: json['label'] as String,
query: json['query'] as String,
createdAt: DateTime.fromMillisecondsSinceEpoch(
json['createdAtMillis'] as int,
),
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
return null;
}
}
}

View File

@@ -0,0 +1,246 @@
import '../models/ble_packet_log.dart';
import '../utils/log_rx_route_decoder.dart';
enum LiveTrafficBusyness { quiet, active, busy }
class LiveTrafficEntry {
final BlePacketLog log;
final DecodedLogRxRoute? route;
const LiveTrafficEntry({required this.log, required this.route});
bool get isMultiHop => (route?.hopCount ?? 0) > 1;
int? get hopCount => route?.hopCount;
String get payloadLabel {
final decodedRoute = route;
if (decodedRoute == null) {
return log.responseCode != null ? log.opcodeName : 'Unknown';
}
return payloadTypeLabel(decodedRoute.payloadType);
}
String? get payloadMeaning {
final decodedRoute = route;
if (decodedRoute == null) return null;
return payloadTypeMeaning(decodedRoute.payloadType);
}
String get routePreview {
final decodedRoute = route;
if (decodedRoute == null || decodedRoute.hopHashes.isEmpty) {
return 'Direct packet';
}
return decodedRoute.hopHashes
.map((hashHex) => '0x${hashHex.toUpperCase()}')
.join(' -> ');
}
static String payloadTypeLabel(int payloadType) {
switch (payloadType) {
case 0x00:
return 'Request';
case 0x01:
return 'Response';
case 0x02:
return 'Text message';
case 0x03:
return 'Ack';
case 0x04:
return 'Advertisement';
case 0x05:
return 'Group text';
case 0x06:
return 'Group datagram';
case 0x07:
return 'Anonymous request';
case 0x08:
return 'Returned path';
case 0x09:
return 'Trace path';
case 0x0A:
return 'Multipart packet';
case 0x0B:
return 'Control packet';
default:
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
}
}
static String payloadTypeMeaning(int payloadType) {
switch (payloadType) {
case 0x00:
return 'Request (destination/source hashes + MAC)';
case 0x01:
return 'Response to Request or Anonymous request';
case 0x02:
return 'Plain text message';
case 0x03:
return 'Simple acknowledgement';
case 0x04:
return 'Node advertisement';
case 0x05:
return 'Unverified group text message';
case 0x06:
return 'Unverified group datagram';
case 0x07:
return 'Generic anonymous request';
case 0x08:
return 'Returned path payload';
case 0x09:
return 'Trace path collecting hop SNR';
case 0x0A:
return 'One packet from a multipart set';
case 0x0B:
return 'Control or discovery packet';
default:
return 'protocol payload';
}
}
}
class LiveTrafficSnapshot {
final DateTime windowStart;
final Duration windowDuration;
final int packetsPerMinute;
final int rxCount;
final int txCount;
final int totalCount;
final double? avgSnrDb;
final double? latestSnrDb;
final double? avgRssiDbm;
final int? latestRssiDbm;
final int multiHopCount;
final double? avgHopCount;
final List<LiveTrafficEntry> visibleEntries;
final LiveTrafficBusyness busyness;
const LiveTrafficSnapshot({
required this.windowStart,
required this.windowDuration,
required this.packetsPerMinute,
required this.rxCount,
required this.txCount,
required this.totalCount,
required this.avgSnrDb,
required this.latestSnrDb,
required this.avgRssiDbm,
required this.latestRssiDbm,
required this.multiHopCount,
required this.avgHopCount,
required this.visibleEntries,
required this.busyness,
});
}
class LiveTrafficSummary {
static const Duration rollingWindow = Duration(seconds: 60);
static const int maxVisibleEntries = 120;
static const int logRxDataResponseCode = 0x88;
const LiveTrafficSummary._();
static LiveTrafficSnapshot fromLogs(
Iterable<BlePacketLog> logs, {
required DateTime now,
DateTime? clearedAt,
int? preferredHashSize,
Duration window = rollingWindow,
String? packetTypeFilter,
}) {
final windowStart = now.subtract(window);
final effectiveStart = clearedAt != null && clearedAt.isAfter(windowStart)
? clearedAt
: windowStart;
final recentLogs = logs
.where(
(log) =>
log.direction == PacketDirection.rx &&
log.responseCode == logRxDataResponseCode &&
!log.timestamp.isBefore(effectiveStart),
)
.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final entries = <LiveTrafficEntry>[];
for (final log in recentLogs) {
final route = LogRxRouteDecoder.decode(
log.rawData,
preferredHashSize: preferredHashSize,
);
entries.add(LiveTrafficEntry(log: log, route: route));
}
final filteredEntries = packetTypeFilter == null
? entries
: entries
.where((entry) => entry.payloadLabel == packetTypeFilter)
.toList();
var rxCount = 0;
var snrCount = 0;
var snrSum = 0.0;
var rssiCount = 0;
var rssiSum = 0.0;
double? latestSnrDb;
int? latestRssiDbm;
var multiHopCount = 0;
var hopCountTotal = 0;
var hopCountSamples = 0;
for (final entry in filteredEntries) {
rxCount += 1;
final rxInfo = entry.log.logRxDataInfo;
if (rxInfo?.snrDb != null) {
snrCount += 1;
snrSum += rxInfo!.snrDb!;
latestSnrDb = rxInfo.snrDb!;
}
if (rxInfo?.rssiDbm != null) {
rssiCount += 1;
rssiSum += rxInfo!.rssiDbm!.toDouble();
latestRssiDbm = rxInfo.rssiDbm!;
}
final route = entry.route;
if (route != null && route.hopCount > 0) {
hopCountSamples += 1;
hopCountTotal += route.hopCount;
if (route.hopCount > 1) {
multiHopCount += 1;
}
}
}
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
const txCount = 0;
final totalCount = rxCount;
final packetsPerMinute = totalCount;
return LiveTrafficSnapshot(
windowStart: effectiveStart,
windowDuration: window,
packetsPerMinute: packetsPerMinute,
rxCount: rxCount,
txCount: txCount,
totalCount: totalCount,
avgSnrDb: snrCount == 0 ? null : snrSum / snrCount,
latestSnrDb: latestSnrDb,
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
latestRssiDbm: latestRssiDbm,
multiHopCount: multiHopCount,
avgHopCount: hopCountSamples == 0 ? null : hopCountTotal / hopCountSamples,
visibleEntries: visibleEntries,
busyness: _busynessForPacketsPerMinute(packetsPerMinute),
);
}
static LiveTrafficBusyness _busynessForPacketsPerMinute(int ppm) {
if (ppm <= 5) return LiveTrafficBusyness.quiet;
if (ppm <= 20) return LiveTrafficBusyness.active;
return LiveTrafficBusyness.busy;
}
}

View File

@@ -54,6 +54,7 @@ class PathHistoryService {
pathBytes: contact.routePathBytes.toList(),
hopCount: contact.routeHopCount,
hashSize: contact.routeHashSize,
source: existing?.source ?? PathRecordSource.learned,
successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
@@ -98,6 +99,7 @@ class PathHistoryService {
pathBytes: normalizedPathBytes,
hopCount: normalizedPathBytes.length ~/ hashSize,
hashSize: hashSize,
source: PathRecordSource.observed,
successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
@@ -197,6 +199,7 @@ class PathHistoryService {
pathBytes: selection.pathBytes.toList(),
hopCount: selection.hopCount,
hashSize: selection.hashSize,
source: existing?.source ?? PathRecordSource.learned,
successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
lastRoundTripTimeMs: success

View File

@@ -30,6 +30,8 @@ class ResolvedNodeHash {
final bool isOwnNode;
final bool isUniqueMatch;
final int matchCount;
final double? latitude;
final double? longitude;
const ResolvedNodeHash({
required this.hashHex,
@@ -37,6 +39,8 @@ class ResolvedNodeHash {
required this.isOwnNode,
required this.isUniqueMatch,
required this.matchCount,
this.latitude,
this.longitude,
});
String get hexLabel => '0x${hashHex.toUpperCase()}';
@@ -179,6 +183,8 @@ class LogRxRouteDecoder {
required Iterable<Contact> contacts,
Uint8List? ownPublicKey,
String? ownName,
double? ownLatitude,
double? ownLongitude,
}) {
final normalizedHashHex = hashHex.toLowerCase();
final ownKeyHex = _bytesToHex(ownPublicKey);
@@ -192,6 +198,8 @@ class LogRxRouteDecoder {
isOwnNode: true,
isUniqueMatch: true,
matchCount: 1,
latitude: ownLatitude,
longitude: ownLongitude,
);
}
@@ -210,12 +218,15 @@ class LogRxRouteDecoder {
}
if (matches.length == 1) {
final location = matches.first.displayLocation;
return ResolvedNodeHash(
hashHex: normalizedHashHex,
label: matches.first.displayName,
isOwnNode: false,
isUniqueMatch: true,
matchCount: 1,
latitude: location?.latitude,
longitude: location?.longitude,
);
}

View File

@@ -357,13 +357,62 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
final lastSeen = MaterialLocalizations.of(
context,
).formatShortDate(record.lastUsedAt);
final sourceLabel = switch (record.source) {
PathRecordSource.observed => 'Observed on mesh',
PathRecordSource.learned => 'Learned route',
};
final successRate = attempts == 0
? 'No send stats yet'
: '${(record.successRate * 100).round()}% success over $attempts send${attempts == 1 ? '' : 's'}';
final latency = record.lastRoundTripTimeMs > 0
? '${record.lastRoundTripTimeMs} ms'
: '';
return '$successRate • Last used $lastSeen$latency';
return '$sourceLabel$successRate • Last used $lastSeen$latency';
}
Widget _buildHistoryRecordTile(PathRecord record, {String? title}) {
final canonicalText = _canonicalRouteFromBytes(
record.pathBytes,
hashSize: record.hashSize,
);
return Card(
margin: EdgeInsets.zero,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
leading: title == null ? null : const Icon(Icons.alt_route),
title: title == null
? Text(
canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 6),
Text(
canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(_historySubtitle(record)),
),
trailing: FilledButton.tonal(
onPressed: () => _applyHistoryRecord(record),
child: const Text('Use'),
),
),
);
}
Widget _buildPreviewSection() {
@@ -586,41 +635,44 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
);
}
return ListView.separated(
itemCount: records.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final record = records[index];
final canonicalText = _canonicalRouteFromBytes(
record.pathBytes,
hashSize: record.hashSize,
);
return Card(
margin: EdgeInsets.zero,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
title: Text(
canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(_historySubtitle(record)),
),
trailing: FilledButton.tonal(
onPressed: () => _applyHistoryRecord(record),
child: const Text('Use'),
),
PathRecord? observedRecord;
for (final record in records) {
if (record.source == PathRecordSource.observed) {
observedRecord = record;
break;
}
}
final remainingRecords = observedRecord == null
? records
: records
.where((record) => !identical(record, observedRecord))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (observedRecord != null) ...[
_buildHistoryRecordTile(observedRecord, title: 'Observed mesh route'),
const SizedBox(height: 16),
],
if (remainingRecords.isEmpty)
Text(
observedRecord == null
? 'No additional route history yet.'
: 'Observed routes you start using will continue to build history here.',
style: Theme.of(context).textTheme.bodyMedium,
)
else
ListView.separated(
itemCount: remainingRecords.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
return _buildHistoryRecordTile(remainingRecords[index]);
},
),
);
},
],
);
}