diff --git a/lib/models/channel.dart b/lib/models/channel.dart index 8833217..03ae8dc 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -122,6 +122,15 @@ class Channel { /// Base64-encoded PSK for sharing with firmware CLI and related tooling. String get pskBase64 => base64.encode(secret); + /// MeshCore group packets carry the first byte of SHA256(channel secret). + int get hashByte { + final digest = sha256.convert(secret); + return digest.bytes.first; + } + + String get hashHex => + hashByte.toRadixString(16).padLeft(2, '0').toUpperCase(); + /// Display name for the channel /// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N" String get displayName { diff --git a/lib/providers/channels_provider.dart b/lib/providers/channels_provider.dart index 3a9b5f2..da8957e 100644 --- a/lib/providers/channels_provider.dart +++ b/lib/providers/channels_provider.dart @@ -29,6 +29,16 @@ class ChannelsProvider with ChangeNotifier { return index == 0 ? 'Public' : 'Channel $index'; } + List getChannelsByHashByte(int hashByte) { + return _channels.values.where((channel) => channel.hashByte == hashByte).toList() + ..sort((a, b) => a.index.compareTo(b.index)); + } + + String? getUniqueChannelDisplayNameByHashByte(int hashByte) { + final matches = getChannelsByHashByte(hashByte); + return matches.length == 1 ? matches.single.displayName : null; + } + /// Add or update a channel void addOrUpdateChannel({ required int index, diff --git a/lib/screens/live_traffic_screen.dart b/lib/screens/live_traffic_screen.dart index 28f8f7c..50a85e8 100644 --- a/lib/screens/live_traffic_screen.dart +++ b/lib/screens/live_traffic_screen.dart @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import '../models/ble_packet_log.dart'; import '../models/contact.dart'; +import '../providers/channels_provider.dart'; import '../providers/contacts_provider.dart'; import '../providers/connection_provider.dart'; import '../services/live_traffic_summary.dart'; @@ -155,6 +156,11 @@ class _LiveTrafficScreenState extends State { tooltip: 'Open packet logs', icon: const Icon(Icons.list_alt_rounded), ), + IconButton( + onPressed: () => _showPacketTypeHelpSheet(context), + tooltip: 'Packet type help', + icon: const Icon(Icons.help_outline), + ), IconButton( onPressed: () { setState(() { @@ -278,6 +284,68 @@ class _LiveTrafficScreenState extends State { _selectedWindow = selected; }); } + + Future _showPacketTypeHelpSheet(BuildContext context) { + final packetTypes = LiveTrafficEntry.knownPayloadTypes; + return showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (context) { + final scheme = Theme.of(context).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: ListView( + shrinkWrap: true, + children: [ + Text( + 'Packet Types', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + 'Descriptions below follow the current MeshCore payload definitions.', + style: TextStyle(color: scheme.onSurfaceVariant), + ), + const SizedBox(height: 16), + for (final packetType in packetTypes) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + packetType.title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + packetType.description, + style: TextStyle( + fontSize: 13, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(height: 10), + ], + ], + ), + ), + ); + }, + ); + } } class _SummaryPanel extends StatelessWidget { @@ -676,7 +744,11 @@ class _LiveTrafficCard extends StatelessWidget { final accent = isRx ? Colors.green : Colors.blue; final rxInfo = log.logRxDataInfo; final originDistance = _originDistanceLabel(context, entry); - final packetDetails = _LiveTrafficPacketDetails.fromEntry(entry); + final channelsProvider = _maybeProvider(context); + final packetDetails = _LiveTrafficPacketDetails.fromEntry( + entry, + channelsProvider: channelsProvider, + ); final signalMetric = SignalMetric.fromRxInfo(rxInfo); return Material( @@ -730,7 +802,7 @@ class _LiveTrafficCard extends StatelessWidget { if (entry.payloadMeaning != null) Text( entry.payloadMeaning!, - maxLines: 1, + maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11, @@ -989,27 +1061,22 @@ class _LiveTrafficPacketDetails { required this.endpointLine, }); - factory _LiveTrafficPacketDetails.fromEntry(LiveTrafficEntry entry) { + factory _LiveTrafficPacketDetails.fromEntry( + LiveTrafficEntry entry, { + ChannelsProvider? channelsProvider, + }) { final route = entry.route; final payloadType = route?.payloadType; final parsedPayload = _ParsedTrafficPayload.tryParse( entry.log.rawData, route, + channelsProvider: channelsProvider, ); final title = switch (payloadType) { - 0x00 => 'FLOOD REQUEST', - 0x01 => 'FLOOD RESPONSE', - 0x02 => 'FLOOD TEXT', - 0x03 => 'FLOOD ACK', - 0x04 => 'FLOOD ADVERTISEMENT', - 0x05 => 'FLOOD GROUP_TEXT', - 0x06 => 'FLOOD GROUP_DATA', - 0x07 => 'FLOOD ANON_REQUEST', - 0x08 => 'FLOOD RETURNED_PATH', - 0x09 => 'FLOOD TRACE_PATH', - 0x0A => 'FLOOD MULTIPART', - 0x0B => 'FLOOD CONTROL', - _ => entry.payloadLabel.toUpperCase(), + 0x05 => parsedPayload?.channelDisplayName ?? LiveTrafficEntry.payloadTypeTitle(0x05), + 0x06 => parsedPayload?.channelDisplayName ?? LiveTrafficEntry.payloadTypeTitle(0x06), + null => entry.payloadLabel.toUpperCase(), + _ => LiveTrafficEntry.payloadTypeTitle(payloadType), }; final hopHashes = route?.hopHashes ?? const []; @@ -1041,13 +1108,15 @@ class _LiveTrafficPacketDetails { class _ParsedTrafficPayload { final String? endpointLine; + final String? channelDisplayName; - const _ParsedTrafficPayload({this.endpointLine}); + const _ParsedTrafficPayload({this.endpointLine, this.channelDisplayName}); static _ParsedTrafficPayload? tryParse( List rawData, - DecodedLogRxRoute? route, - ) { + DecodedLogRxRoute? route, { + ChannelsProvider? channelsProvider, + }) { if (rawData.length < 5 || rawData.first != LiveTrafficSummary.logRxDataResponseCode) { return null; @@ -1084,9 +1153,18 @@ class _ParsedTrafficPayload { case 0x05: case 0x06: if (payload.isEmpty) return const _ParsedTrafficPayload(); + final channelHash = payload.first; + final channelHashHex = channelHash + .toRadixString(16) + .padLeft(2, '0') + .toUpperCase(); + final channelDisplayName = channelsProvider + ?.getUniqueChannelDisplayNameByHashByte(channelHash); return _ParsedTrafficPayload( - endpointLine: - 'Channel Hash: ${payload.first.toRadixString(16).padLeft(2, '0').toUpperCase()}', + channelDisplayName: channelDisplayName, + endpointLine: channelDisplayName == null + ? 'Channel Hash: $channelHashHex' + : 'Channel: $channelDisplayName ($channelHashHex)', ); case 0x00: case 0x01: diff --git a/lib/services/live_traffic_summary.dart b/lib/services/live_traffic_summary.dart index 5c42bf7..8e7b27d 100644 --- a/lib/services/live_traffic_summary.dart +++ b/lib/services/live_traffic_summary.dart @@ -3,12 +3,136 @@ import '../utils/log_rx_route_decoder.dart'; enum LiveTrafficBusyness { quiet, active, busy } +class LiveTrafficPacketTypeDetails { + final int payloadType; + final String title; + final String label; + final String summary; + final String description; + + const LiveTrafficPacketTypeDetails({ + required this.payloadType, + required this.title, + required this.label, + required this.summary, + required this.description, + }); +} + class LiveTrafficEntry { final BlePacketLog log; final DecodedLogRxRoute? route; const LiveTrafficEntry({required this.log, required this.route}); + static const List _knownPayloadTypes = [ + LiveTrafficPacketTypeDetails( + payloadType: 0x00, + title: 'FLOOD REQUEST', + label: 'Request', + summary: 'Encrypted request to a known peer', + description: + 'Encrypted request to a known peer. The wire payload carries destination and source hashes plus a MAC, and the decrypted body starts with a timestamp followed by application-defined request data such as stats or keepalive requests.', + ), + LiveTrafficPacketTypeDetails( + payloadType: 0x01, + title: 'FLOOD RESPONSE', + label: 'Response', + summary: 'Encrypted reply to a request', + description: + 'Encrypted reply to a Request or Anonymous request. After decryption, the body is application-defined response data with no single generic response envelope.', + ), + LiveTrafficPacketTypeDetails( + payloadType: 0x02, + title: 'FLOOD TEXT', + label: 'Text message', + summary: 'Encrypted direct text with timestamp and retry flags', + description: + 'Encrypted direct text message to a known peer. The decrypted body contains a timestamp, a flags and attempt byte, and the message text.', + ), + LiveTrafficPacketTypeDetails( + payloadType: 0x03, + title: 'FLOOD ACK', + label: 'Ack', + summary: '4-byte acknowledgement for an earlier message', + description: + 'Short acknowledgement proving that a prior message was received. It carries a 4-byte checksum derived from the original message data.', + ), + LiveTrafficPacketTypeDetails( + payloadType: 0x04, + title: 'FLOOD ADVERTISEMENT', + label: 'Advertisement', + summary: 'Signed node identity broadcast', + description: + 'Signed node advertisement announcing a device identity plus app data such as a name or location. Receivers verify the signature before accepting it.', + ), + LiveTrafficPacketTypeDetails( + payloadType: 0x05, + title: 'FLOOD GROUP_TEXT', + label: 'Group text', + summary: 'Encrypted channel text matched by channel hash', + description: + 'Encrypted channel text message. It is matched by the first byte of SHA256(channel secret), then decrypted with the channel key. The plaintext is usually in the form ": ".', + ), + LiveTrafficPacketTypeDetails( + payloadType: 0x06, + title: 'FLOOD GROUP_DATA', + label: 'Group datagram', + summary: 'Encrypted channel data with type and length', + description: + 'Encrypted channel datagram. After channel-hash matching and decryption, the body starts with a 16-bit data type and a 1-byte data length before the application payload.', + ), + LiveTrafficPacketTypeDetails( + payloadType: 0x07, + title: 'FLOOD ANON_REQUEST', + label: 'Anonymous request', + summary: 'Request using an ephemeral sender key', + description: + 'Encrypted request to a destination hash without using a stored sender identity. The packet includes the sender\'s ephemeral public key so the receiver can derive the shared secret.', + ), + LiveTrafficPacketTypeDetails( + payloadType: 0x08, + title: 'FLOOD RETURNED_PATH', + label: 'Returned path', + summary: + 'Return route back to the sender, with optional bundled ACK or response', + description: + 'Path reply sent back to the original author to describe the route a received packet took. MeshCore stores that returned path as the peer\'s direct out-path and can bundle an ACK or response in the same payload.', + ), + LiveTrafficPacketTypeDetails( + payloadType: 0x09, + title: 'FLOOD TRACE_PATH', + label: 'Trace path', + summary: 'Direct trace that records SNR at each hop', + description: + 'Direct diagnostic packet that walks a supplied path and appends one SNR sample per hop. When it reaches the end of the path, the initiator can inspect hop-by-hop link quality.', + ), + LiveTrafficPacketTypeDetails( + payloadType: 0x0A, + title: 'FLOOD MULTIPART', + label: 'Multipart packet', + summary: 'Wrapper for one packet in a multipart sequence', + description: + 'Packet wrapper used when a logical message is split into a sequence. Current MeshCore code uses it for multipart ACKs, where the first nibble says how many parts remain.', + ), + LiveTrafficPacketTypeDetails( + payloadType: 0x0B, + title: 'FLOOD CONTROL', + label: 'Control packet', + summary: 'Discovery or other control data', + description: + 'Control or discovery payload, typically unencrypted. Current documented subtypes are discovery request and response packets used to find nearby nodes and report SNR.', + ), + LiveTrafficPacketTypeDetails( + payloadType: 0x0F, + title: 'RAW CUSTOM', + label: 'Custom packet', + summary: 'Application-defined custom packet', + description: + 'Application-defined raw packet bytes for custom encryption or custom payload formats. MeshCore leaves the inner format up to the higher-level application.', + ), + ]; + bool get isMultiHop => (route?.hopCount ?? 0) > 1; int? get hopCount => route?.hopCount; @@ -37,66 +161,39 @@ class LiveTrafficEntry { .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 List get knownPayloadTypes => + _knownPayloadTypes; + + static LiveTrafficPacketTypeDetails payloadTypeDetails(int payloadType) { + for (final details in _knownPayloadTypes) { + if (details.payloadType == payloadType) { + return details; + } } + return LiveTrafficPacketTypeDetails( + payloadType: payloadType, + title: '0x${payloadType.toRadixString(16).padLeft(2, '0').toUpperCase()}', + label: '0x${payloadType.toRadixString(16).padLeft(2, '0')}', + summary: 'Unknown or application-specific protocol payload', + description: + 'Unknown or application-specific packet type. Check the current MeshCore firmware or app-specific protocol docs for the exact payload format.', + ); + } + + static String payloadTypeLabel(int payloadType) { + return payloadTypeDetails(payloadType).label; + } + + static String payloadTypeTitle(int payloadType) { + return payloadTypeDetails(payloadType).title; } 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'; - } + return payloadTypeDetails(payloadType).summary; + } + + static String payloadTypeDescription(int payloadType) { + return payloadTypeDetails(payloadType).description; } } diff --git a/test/providers/channels_provider_test.dart b/test/providers/channels_provider_test.dart index 09eef34..df04357 100644 --- a/test/providers/channels_provider_test.dart +++ b/test/providers/channels_provider_test.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/channel.dart'; import 'package:meshcore_sar_app/providers/channels_provider.dart'; void main() { @@ -22,4 +23,38 @@ void main() { expect(provider.selectedChannel, isNull); }); }); + + group('ChannelsProvider mesh hash lookup', () { + test('resolves a unique channel display name by hash byte', () { + final provider = ChannelsProvider(); + final channel = Channel.create(index: 3, name: '#ops'); + + provider.addOrUpdateChannelObject(channel); + + expect( + provider.getUniqueChannelDisplayNameByHashByte(channel.hashByte), + '#ops', + ); + }); + + test('does not resolve ambiguous hash matches', () { + final provider = ChannelsProvider(); + final secret = Uint8List.fromList(List.filled(16, 7)); + final firstChannel = Channel.create( + index: 1, + name: 'Ops 1', + explicitSecret: secret, + ); + + provider.addOrUpdateChannelObject(firstChannel); + provider.addOrUpdateChannelObject( + Channel.create(index: 2, name: 'Ops 2', explicitSecret: secret), + ); + + expect( + provider.getUniqueChannelDisplayNameByHashByte(firstChannel.hashByte), + isNull, + ); + }); + }); } diff --git a/test/screens/live_traffic_screen_test.dart b/test/screens/live_traffic_screen_test.dart index 6e18ae0..e0f282d 100644 --- a/test/screens/live_traffic_screen_test.dart +++ b/test/screens/live_traffic_screen_test.dart @@ -2,9 +2,12 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/channel.dart'; import 'package:meshcore_sar_app/models/ble_packet_log.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/providers/channels_provider.dart'; import 'package:meshcore_sar_app/screens/live_traffic_screen.dart'; +import 'package:provider/provider.dart'; BlePacketLog _log({ required DateTime timestamp, @@ -51,12 +54,16 @@ List _multiHopRaw({ ]; } -Widget _testApp(Widget child) { - return MaterialApp( +Widget _testApp(Widget child, {ChannelsProvider? channelsProvider}) { + final app = MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: child, ); + if (channelsProvider == null) { + return app; + } + return ChangeNotifierProvider.value(value: channelsProvider, child: app); } void main() { @@ -190,6 +197,82 @@ void main() { expect(find.textContaining('Size: 3 bytes'), findsOneWidget); }); + testWidgets('shows known channel name for group traffic', (tester) async { + final logs = []; + final refresh = ValueNotifier(0); + final channel = Channel.create(index: 3, name: '#ops'); + final channelsProvider = ChannelsProvider() + ..initializePublicChannel() + ..addOrUpdateChannelObject(channel); + final now = DateTime(2026, 3, 12, 12, 0, 0); + + logs.add( + _log( + timestamp: now.subtract(const Duration(seconds: 4)), + direction: PacketDirection.rx, + rawData: [ + ..._multiHopRaw(hops: [0xC0, 0x10], payloadType: 0x05), + channel.hashByte, + ], + responseCode: 0x88, + ), + ); + + await tester.pumpWidget( + _testApp( + LiveTrafficScreen( + logReader: () => logs, + refreshListenable: refresh, + now: () => now, + ), + channelsProvider: channelsProvider, + ), + ); + + expect(find.text('#ops'), findsOneWidget); + expect(find.text('Channel: #ops (${channel.hashHex})'), findsOneWidget); + expect(find.text('FLOOD GROUP_TEXT'), findsNothing); + }); + + testWidgets('shows packet type help sheet from the app bar', (tester) async { + final logs = []; + final refresh = ValueNotifier(0); + final now = DateTime(2026, 3, 12, 12, 0, 0); + + await tester.pumpWidget( + _testApp( + LiveTrafficScreen( + logReader: () => logs, + refreshListenable: refresh, + now: () => now, + ), + ), + ); + + await tester.tap(find.byTooltip('Packet type help')); + await tester.pumpAndSettle(); + + expect(find.text('Packet Types'), findsOneWidget); + await tester.scrollUntilVisible( + find.text('FLOOD RETURNED_PATH'), + 300, + scrollable: find.byType(Scrollable).last, + ); + await tester.pumpAndSettle(); + expect(find.text('FLOOD RETURNED_PATH'), findsOneWidget); + expect( + find.textContaining('stores that returned path as the peer\'s direct out-path'), + findsOneWidget, + ); + await tester.scrollUntilVisible( + find.text('FLOOD CONTROL'), + 300, + scrollable: find.byType(Scrollable).last, + ); + await tester.pumpAndSettle(); + expect(find.text('FLOOD CONTROL'), findsOneWidget); + }); + testWidgets('summary metrics expand across wide layouts', (tester) async { tester.view.physicalSize = const Size(1200, 900); tester.view.devicePixelRatio = 1; diff --git a/test/services/live_traffic_summary_test.dart b/test/services/live_traffic_summary_test.dart index 816d0ec..9437d31 100644 --- a/test/services/live_traffic_summary_test.dart +++ b/test/services/live_traffic_summary_test.dart @@ -196,5 +196,18 @@ void main() { 1, ); }); + + test('exposes detailed descriptions for known packet types', () { + final returnedPath = LiveTrafficEntry.payloadTypeDetails(0x08); + final control = LiveTrafficEntry.payloadTypeDetails(0x0B); + + expect(returnedPath.title, 'FLOOD RETURNED_PATH'); + expect( + returnedPath.description, + contains('stores that returned path as the peer\'s direct out-path'), + ); + expect(control.label, 'Control packet'); + expect(control.description, contains('discovery request and response')); + }); }); }