From 79a45c527b7293d393d37160049c17ff78b56d03 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sat, 14 Mar 2026 11:45:47 -0700 Subject: [PATCH 01/10] Unify contact retrieval by introducing allContacts getter --- lib/connector/meshcore_connector.dart | 17 +++++++++++++++-- lib/screens/channel_message_path_screen.dart | 10 ++-------- lib/screens/chat_screen.dart | 2 +- lib/screens/map_screen.dart | 5 +---- lib/screens/neighbors_screen.dart | 5 +---- lib/screens/path_trace_map.dart | 5 +---- lib/widgets/path_management_dialog.dart | 2 +- lib/widgets/path_selection_dialog.dart | 3 ++- lib/widgets/snr_indicator.dart | 5 +---- 9 files changed, 25 insertions(+), 29 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 7cf32ef..dad5ed1 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -289,6 +289,10 @@ class MeshCoreConnector extends ChangeNotifier { ); } + List get allContacts => List.unmodifiable([ + ..._contacts, + ..._discoveredContacts.where((c) => !c.isActive), + ]); List get discoveredContacts { return List.unmodifiable(_discoveredContacts); } @@ -2909,6 +2913,8 @@ class MeshCoreConnector extends ChangeNotifier { void _handleContact(Uint8List frame, {bool isContact = true}) { final contact = Contact.fromFrame(frame); if (contact != null) { + _handleDiscovery(contact, frame, noNotify: true, addActive: true); + if (contact.type == advTypeRepeater) { _contactUnreadCount.remove(contact.publicKeyHex); _unreadStore.saveContactUnreadCount( @@ -4717,6 +4723,12 @@ class MeshCoreConnector extends ChangeNotifier { (_autoAddRoomServers && type == advTypeRoom) || (_autoAddSensors && type == advTypeSensor)) { _handleContactAdvert(newContact); + _handleDiscovery( + newContact, + rawPacket, + noNotify: true, + addActive: true, + ); } else { _handleDiscovery(newContact, rawPacket); } @@ -4827,6 +4839,7 @@ class MeshCoreConnector extends ChangeNotifier { Contact contact, Uint8List rawPacket, { bool noNotify = false, + bool addActive = false, }) { appLogger.info('Discovered new contact: ${contact.name}', tag: 'Connector'); @@ -4847,7 +4860,7 @@ class MeshCoreConnector extends ChangeNotifier { longitude: contact.longitude, lastSeen: contact.lastSeen, flags: 0, - isActive: false, + isActive: addActive, ); notifyListeners(); unawaited(_persistDiscoveredContacts()); @@ -4865,7 +4878,7 @@ class MeshCoreConnector extends ChangeNotifier { longitude: contact.longitude, lastSeen: contact.lastSeen, lastMessageAt: contact.lastMessageAt, - isActive: false, + isActive: addActive, flags: 0, ); _discoveredContacts.add(disContact); diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index c2c57f0..32eadef 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -40,10 +40,7 @@ class ChannelMessagePathScreen extends StatelessWidget { final primaryPath = !channelMessage && !message.isOutgoing ? Uint8List.fromList(primaryPathTmp.reversed.toList()) : primaryPathTmp; - final contacts = [ - ...connector.contacts, - ...connector.discoveredContacts, - ]; + final contacts = connector.allContacts; final hops = _buildPathHops(primaryPath, contacts, l10n); final hasHopDetails = primaryPath.isNotEmpty; final observedLabel = _formatObservedHops( @@ -367,10 +364,7 @@ class _ChannelMessagePathMapScreenState : selectedPathTmp; final selectedIndex = _indexForPath(selectedPath, observedPaths); - final contacts = [ - ...connector.contacts, - ...connector.discoveredContacts, - ]; + final contacts = connector.allContacts; final hops = _buildPathHops(selectedPath, contacts, context.l10n); final points = []; diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 96203ea..6558ecd 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1027,7 +1027,7 @@ class _ChatScreenState extends State { final currentPathLabel = _currentPathLabel(currentContact); // Filter out the current contact from available contacts - final availableContacts = connector.contacts + final availableContacts = connector.allContacts .where((c) => c != widget.contact) .toList(); diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 1dd3a5f..497c05f 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -137,10 +137,7 @@ class _MapScreenState extends State { builder: (context, connector, settingsService, pathHistory, child) { final tileCache = context.read(); final settings = settingsService.settings; - final allContacts = [ - ...connector.contacts, - ...connector.discoveredContacts.where((c) => !c.isActive), - ]; + final allContacts = connector.allContacts; final contacts = settings.mapShowDiscoveryContacts ? allContacts diff --git a/lib/screens/neighbors_screen.dart b/lib/screens/neighbors_screen.dart index 5cb8e45..5afeda4 100644 --- a/lib/screens/neighbors_screen.dart +++ b/lib/screens/neighbors_screen.dart @@ -124,10 +124,7 @@ class _NeighborsScreenState extends State { void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) { final buffer = BufferReader(frame); - final contacts = [ - ...connector.contacts, - ...connector.discoveredContacts, - ]; + final contacts = connector.allContacts; try { final neighborCount = buffer.readUInt16LE(); final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE()); diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index ceb60a6..6277886 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -263,10 +263,7 @@ class _PathTraceMapScreenState extends State { .toList(); Map pathContacts = {}; - final contacts = [ - ...connector.contacts, - ...connector.discoveredContacts, - ]; + final contacts = connector.allContacts; contacts.where((c) => c.type != advTypeChat).forEach((repeater) { for (var repeaterData in pathData) { if (listEquals( diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index 384f92b..0233b43 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -107,7 +107,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> { } final pathForInput = currentContact.pathIdList; - final availableContacts = connector.contacts + final availableContacts = connector.allContacts .where((c) => c.publicKeyHex != currentContact.publicKeyHex) .toList(); diff --git a/lib/widgets/path_selection_dialog.dart b/lib/widgets/path_selection_dialog.dart index 4e6cfe5..b1733fc 100644 --- a/lib/widgets/path_selection_dialog.dart +++ b/lib/widgets/path_selection_dialog.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; @@ -65,7 +66,7 @@ class _PathSelectionDialogState extends State { void _filterValidContacts() { _validContacts = widget.availableContacts - .where((c) => c.type == 2 || c.type == 3) + .where((c) => c.type == advTypeRepeater || c.type == advTypeRoom) .toList(); } diff --git a/lib/widgets/snr_indicator.dart b/lib/widgets/snr_indicator.dart index f122836..30956e2 100644 --- a/lib/widgets/snr_indicator.dart +++ b/lib/widgets/snr_indicator.dart @@ -157,10 +157,7 @@ class _SNRIndicatorState extends State { repeater.snr, widget.connector.currentSf, ); - final allContacts = [ - ...widget.connector.contacts, - ...widget.connector.discoveredContacts, - ]; + final allContacts = widget.connector.allContacts; final name = allContacts .where((c) => c.publicKey.first == repeater.pubkeyFirstByte) .map((c) => c.name) From 24fa78741b2fb8094be33dbabea974e801f2eee9 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sat, 14 Mar 2026 11:46:05 -0700 Subject: [PATCH 02/10] add TCP server address and port settings to AppSettings and update TcpScreen --- lib/models/app_settings.dart | 12 ++++++++++++ lib/screens/tcp_screen.dart | 18 ++++++++++++++++-- lib/services/app_settings_service.dart | 8 ++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index c89ac27..fc84851 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -40,6 +40,8 @@ class AppSettings { final UnitSystem unitSystem; final Set mutedChannels; final bool mapShowDiscoveryContacts; + final String tcpServerAddress; + final int tcpServerPort; AppSettings({ this.clearPathOnMaxRetry = false, @@ -68,6 +70,8 @@ class AppSettings { this.unitSystem = UnitSystem.metric, Set? mutedChannels, this.mapShowDiscoveryContacts = true, + this.tcpServerAddress = '', + this.tcpServerPort = 0, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}, mutedChannels = mutedChannels ?? {}; @@ -100,6 +104,8 @@ class AppSettings { 'unit_system': unitSystem.value, 'muted_channels': mutedChannels.toList(), 'map_show_discovery_contacts': mapShowDiscoveryContacts, + 'tcp_server_address': tcpServerAddress, + 'tcp_server_port': tcpServerPort, }; } @@ -157,6 +163,8 @@ class AppSettings { {}, mapShowDiscoveryContacts: json['map_show_discovery_contacts'] as bool? ?? true, + tcpServerAddress: json['tcp_server_address'] as String? ?? '', + tcpServerPort: json['tcp_server_port'] as int? ?? 0, ); } @@ -187,6 +195,8 @@ class AppSettings { UnitSystem? unitSystem, Set? mutedChannels, bool? mapShowDiscoveryContacts, + String? tcpServerAddress, + int? tcpServerPort, }) { return AppSettings( clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry, @@ -225,6 +235,8 @@ class AppSettings { mutedChannels: mutedChannels ?? this.mutedChannels, mapShowDiscoveryContacts: mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts, + tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress, + tcpServerPort: tcpServerPort ?? this.tcpServerPort, ); } } diff --git a/lib/screens/tcp_screen.dart b/lib/screens/tcp_screen.dart index cf87382..02b9b5a 100644 --- a/lib/screens/tcp_screen.dart +++ b/lib/screens/tcp_screen.dart @@ -1,10 +1,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:meshcore_open/models/app_settings.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; +import '../services/app_settings_service.dart'; import '../utils/platform_info.dart'; import '../widgets/adaptive_app_bar_title.dart'; import 'contacts_screen.dart'; @@ -27,8 +29,14 @@ class _TcpScreenState extends State { @override void initState() { super.initState(); - _hostController = TextEditingController(); - _portController = TextEditingController(text: '5000'); + _hostController = TextEditingController( + text: context.read().settings.tcpServerAddress, + ); + _portController = TextEditingController( + text: context.read().settings.tcpServerPort > 0 + ? context.read().settings.tcpServerPort.toString() + : '', + ); _connector = context.read(); _connectionListener = () { @@ -39,6 +47,12 @@ class _TcpScreenState extends State { if (_connector.state == MeshCoreConnectionState.connected && _connector.isTcpTransportConnected && !_navigatedToContacts) { + context.read().setTcpServerAddress( + _hostController.text, + ); + context.read().setTcpServerPort( + int.tryParse(_portController.text) ?? 0, + ); _navigatedToContacts = true; Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => const ContactsScreen()), diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index a52e364..88c1f81 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -182,4 +182,12 @@ class AppSettingsService extends ChangeNotifier { ..remove(channelName); await updateSettings(_settings.copyWith(mutedChannels: updated)); } + + Future setTcpServerAddress(String value) async { + await updateSettings(_settings.copyWith(tcpServerAddress: value)); + } + + Future setTcpServerPort(int value) async { + await updateSettings(_settings.copyWith(tcpServerPort: value)); + } } From 06a906f4f71ec8e33aaebb26ee7242a7b9ff1039 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sat, 14 Mar 2026 17:51:24 -0700 Subject: [PATCH 03/10] Enhance location handling and improve path trace functionality across screens --- lib/connector/meshcore_connector.dart | 16 +++- lib/models/contact.dart | 54 +++-------- lib/screens/channel_message_path_screen.dart | 5 +- lib/screens/chat_screen.dart | 2 +- lib/screens/contacts_screen.dart | 17 ++-- lib/screens/map_screen.dart | 11 +-- lib/screens/path_trace_map.dart | 99 +++++++++++++++----- lib/services/app_debug_log_service.dart | 17 ++-- lib/utils/app_logger.dart | 15 +-- lib/widgets/path_management_dialog.dart | 2 +- 10 files changed, 138 insertions(+), 100 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index dad5ed1..86484d8 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -4753,8 +4753,20 @@ class MeshCoreConnector extends ChangeNotifier { // CRITICAL: Preserve user's path override when contact is refreshed from device _contacts[existingIndex] = existing.copyWith( - latitude: hasLocation ? latitude : existing.latitude, - longitude: hasLocation ? longitude : existing.longitude, + latitude: + hasLocation && + latitude != null && + latitude.abs() <= 90 && + longitude != 0 + ? latitude + : existing.latitude, + longitude: + hasLocation && + longitude != null && + longitude.abs() <= 180 && + longitude != 0 + ? longitude + : existing.longitude, name: hasName ? name : existing.name, path: Uint8List.fromList(path.reversed.toList()), pathLength: path.length, diff --git a/lib/models/contact.dart b/lib/models/contact.dart index cab58cb..858d712 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -1,4 +1,5 @@ import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:meshcore_open/utils/app_logger.dart'; import '../connector/meshcore_protocol.dart'; @@ -65,7 +66,17 @@ class Contact { return '$pathLength hops'; } - bool get hasLocation => latitude != null && longitude != null; + bool get hasLocation { + const double epsilon = 1e-6; + final lat = latitude ?? 0.0; + final lon = longitude ?? 0.0; + return (lat.abs() > epsilon || lon.abs() > epsilon) && + lat >= -90.0 && + lat <= 90.0 && + lon >= -180.0 && + lon <= 180.0; + } + bool get isFavorite => (flags & contactFlagFavorite) != 0; Contact copyWith({ @@ -108,7 +119,7 @@ class Contact { } String get pathIdList { - final pathBytes = _pathBytesForDisplay; + final pathBytes = pathBytesForDisplay; if (pathBytes.isEmpty) return ''; final parts = []; final groupSize = pathHashSize; @@ -130,43 +141,7 @@ class Contact { return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>"; } - Uint8List? get traceRouteBytes { - final pathBytes = _pathBytesForDisplay; - Uint8List? traceBytes; - - if (pathBytes.isEmpty) { - traceBytes = Uint8List(1); - traceBytes[0] = publicKey[0]; - return traceBytes; - } - - if (type == advTypeRepeater || type == advTypeRoom) { - final len = (pathBytes.length + pathBytes.length + 1); - traceBytes = Uint8List(len); - traceBytes[pathBytes.length] = publicKey[0]; - for (int i = 0; i < pathBytes.length; i++) { - traceBytes[i] = pathBytes[i]; - if (i < pathBytes.length) { - traceBytes[len - 1 - i] = pathBytes[i]; - } - } - } else { - if (pathBytes.length < 2) { - return pathBytes[0] == 0 ? null : pathBytes; - } - final len = (pathBytes.length + pathBytes.length - 1); - traceBytes = Uint8List(len); - for (int i = 0; i < pathBytes.length; i++) { - traceBytes[i] = pathBytes[i]; - if (i < pathBytes.length - 1) { - traceBytes[len - 1 - i] = pathBytes[i]; - } - } - } - return traceBytes; - } - - Uint8List get _pathBytesForDisplay { + Uint8List get pathBytesForDisplay { if (pathOverride != null) { if (pathOverride! < 0) return Uint8List(0); return pathOverrideBytes ?? Uint8List(0); @@ -197,6 +172,7 @@ class Contact { double? lat, lon; final latRaw = reader.readInt32LE(); final lonRaw = reader.readInt32LE(); + if (latRaw != 0 || lonRaw != 0) { lat = latRaw / 1e6; lon = lonRaw / 1e6; diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 32eadef..747c2bf 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -62,8 +62,9 @@ class ChannelMessagePathScreen extends StatelessWidget { builder: (context) => PathTraceMapScreen( title: context.l10n.contacts_repeaterPathTrace, path: primaryPath, - flipPathRound: true, - reversePathRound: !message.isOutgoing && !channelMessage, + flipPathAround: true, + reversePathAround: + !(!channelMessage && !message.isOutgoing), ), ), ), diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 6558ecd..5209b41 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -858,7 +858,7 @@ class _ChatScreenState extends State { builder: (context) => PathTraceMapScreen( title: context.l10n.contacts_repeaterPathTrace, path: Uint8List.fromList(pathBytes), - flipPathRound: true, + flipPathAround: true, targetContact: widget.contact, ), ), diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 243c8c4..ed2e171 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -1064,7 +1064,7 @@ class _ContactsScreenState extends State if (isRepeater) ...[ ListTile( leading: const Icon(Icons.radar, color: Colors.green), - title: contact.pathLength > 0 + title: contact.pathBytesForDisplay.isNotEmpty ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping), onTap: () { @@ -1072,10 +1072,12 @@ class _ContactsScreenState extends State context, MaterialPageRoute( builder: (context) => PathTraceMapScreen( - title: contact.pathLength > 0 + title: contact.pathBytesForDisplay.isNotEmpty ? context.l10n.contacts_repeaterPathTrace : context.l10n.contacts_repeaterPing, - path: contact.traceRouteBytes ?? Uint8List(0), + path: contact.pathBytesForDisplay, + flipPathAround: true, + targetContact: contact, ), ), ); @@ -1100,10 +1102,12 @@ class _ContactsScreenState extends State context, MaterialPageRoute( builder: (context) => PathTraceMapScreen( - title: contact.pathLength > 0 + title: contact.pathBytesForDisplay.isNotEmpty ? context.l10n.contacts_roomPathTrace : context.l10n.contacts_roomPing, - path: contact.traceRouteBytes ?? Uint8List(0), + path: contact.pathBytesForDisplay, + flipPathAround: contact.pathBytesForDisplay.isNotEmpty, + targetContact: contact, ), ), ); @@ -1145,7 +1149,8 @@ class _ContactsScreenState extends State title: context.l10n.contacts_pathTraceTo( contact.name, ), - path: contact.traceRouteBytes ?? Uint8List(0), + path: contact.pathBytesForDisplay, + flipPathAround: true, targetContact: contact, ), ), diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 497c05f..df16a59 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -176,20 +176,13 @@ class _MapScreenState extends State { // Filter by location final contactsWithLocation = filteredByKeyPrefix.where((c) { - if (!c.hasLocation) { - return false; - } - return _checkLocationPlausibility(c.latitude!, c.longitude!); + return c.hasLocation; }).toList(); // All contacts with a known location — used as anchors regardless of // time/key-prefix filters so that repeaters are always available. final allContactsWithLocation = allContacts - .where( - (c) => - c.hasLocation && - _checkLocationPlausibility(c.latitude!, c.longitude!), - ) + .where((c) => c.hasLocation) .toList(); // Compute guessed locations with caching diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 6277886..d50a185 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -52,8 +52,8 @@ class PathTraceMapScreen extends StatefulWidget { final String title; final Uint8List path; final int? repeaterId; - final bool flipPathRound; - final bool reversePathRound; + final bool flipPathAround; + final bool reversePathAround; final Contact? targetContact; const PathTraceMapScreen({ @@ -61,8 +61,8 @@ class PathTraceMapScreen extends StatefulWidget { required this.title, required this.path, this.repeaterId, - this.flipPathRound = false, - this.reversePathRound = false, + this.flipPathAround = false, + this.reversePathAround = false, this.targetContact, }); @@ -93,6 +93,7 @@ class _PathTraceMapScreenState extends State { ValueKey _mapKey = const ValueKey('initial'); double _pathDistanceMeters = 0.0; bool _showNodeLabels = true; + Contact? target; String _formatPathPrefixes(Uint8List pathBytes) { return pathBytes @@ -158,21 +159,16 @@ class _PathTraceMapScreenState extends State { }); } - final Uint8List path; - - Uint8List pathTmp = widget.reversePathRound + final pathTmp = widget.reversePathAround ? Uint8List.fromList(widget.path.reversed.toList()) : widget.path; - if (widget.flipPathRound) { - path = buildPath(pathTmp); - } else { - path = pathTmp; - } + final path = widget.flipPathAround ? buildPath(pathTmp) : pathTmp; appLogger.info( 'Initiating path trace with path: ${_formatPathPrefixes(path)}', tag: 'PathTraceMapScreen', + noNotify: !mounted, ); final connector = Provider.of(context, listen: false); @@ -309,18 +305,20 @@ class _PathTraceMapScreenState extends State { // Compute endpoint position for the target contact. LatLng? targetPos; bool targetGuessed = false; - final target = widget.targetContact; + target = widget.targetContact; + if (target != null) { - if (target.hasLocation) { - targetPos = LatLng(target.latitude!, target.longitude!); - } else if (pathData.isNotEmpty) { + if (target?.hasLocation ?? false) { + targetPos = LatLng(target!.latitude!, target!.longitude!); + } else if (widget.path.length > 1) { // Infer from the last hop: average GPS contacts sharing that hop. // For a round-trip path (flipPathRound), the target-side hop sits // in the middle of the symmetric sequence; .last is the local side. - final lastHop = (widget.flipPathRound && pathData.length > 1) - ? pathData[(pathData.length - 1) ~/ 2] - : pathData.last; - final peers = connector.contacts + final lastHop = widget.reversePathAround + ? widget.path.first + : widget.path.last; + + final peers = connector.allContacts .where( (c) => c.hasLocation && @@ -336,12 +334,35 @@ class _PathTraceMapScreenState extends State { peers.map((c) => c.longitude!).reduce((a, b) => a + b) / peers.length; const offsetDeg = 0.003; - final angle = (target.publicKey[1] / 255.0) * 2 * pi; + final angle = (target!.publicKey[1] / 255.0) * 2 * pi; targetPos = LatLng( lat + offsetDeg * cos(angle), lon + offsetDeg * sin(angle), ); targetGuessed = true; + } else if (inferredPositions.containsKey(lastHop)) { + final lat = inferredPositions[lastHop]!.latitude; + final lon = inferredPositions[lastHop]!.longitude; + const offsetDeg = 0.003; + final angle = (target!.publicKey[1] / 255.0) * 2 * pi; + targetPos = LatLng( + lat + offsetDeg * cos(angle), + lon + offsetDeg * sin(angle), + ); + targetGuessed = true; + } else { + // As a last resort, just place it at the same position as the last hop. + final contact = pathContacts[lastHop]; + if (contact != null && contact.hasLocation) { + const offsetDeg = 0.003; + final angle = (target!.publicKey[1] / 255.0) * 2 * pi; + targetPos = LatLng( + contact.latitude! + offsetDeg * cos(angle), + contact.longitude! + offsetDeg * sin(angle), + ); + targetGuessed = true; + targetGuessed = true; + } } } } @@ -350,7 +371,12 @@ class _PathTraceMapScreenState extends State { _points = []; _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); + int hopLast = 0; + int hopLastLast = 0; for (final hop in _traceData!.pathData) { + if (hop == hopLastLast && widget.flipPathAround) { + break; //skip duplicate hops in round-trip paths + } final contact = _traceData!.pathContacts[hop]; if (contact != null && contact.hasLocation) { _points.add(LatLng(contact.latitude!, contact.longitude!)); @@ -358,8 +384,14 @@ class _PathTraceMapScreenState extends State { final inferred = inferredPositions[hop]; if (inferred != null) _points.add(inferred); } + hopLastLast = hopLast; + hopLast = hop; + } + if (targetPos != null) { + if (target != null && target!.type == advTypeChat) { + _points.add(targetPos); + } } - if (targetPos != null) _points.add(targetPos); _polylines = _points.length > 1 ? [ Polyline( @@ -448,7 +480,7 @@ class _PathTraceMapScreenState extends State { ], ), ), - if (_hasData) _buildMapPathTrace(context, tileCache), + if (_hasData) _buildMapPathTrace(context, tileCache, target), if (_points.isEmpty && !_hasData && !_isLoading && @@ -477,17 +509,28 @@ class _PathTraceMapScreenState extends State { List _buildHopMarkers( List pathData, { required bool showLabels, + required Contact? target, }) { final markers = []; + int hopLast = 0; + int hopLastLast = 0; for (final hop in pathData) { final contact = _traceData!.pathContacts[hop]; final inferred = _inferredHopPositions[hop]; final hasGps = contact != null && contact.hasLocation; - if (!hasGps && inferred == null) continue; + if (hop == hopLastLast && widget.flipPathAround) { + continue; //skip duplicate hops in round-trip paths + } + if (!hasGps && inferred == null) { + hopLastLast = hopLast; + hopLast = hop; + continue; //skip hops with no GPS and no inferred position + } final point = hasGps ? LatLng(contact.latitude!, contact.longitude!) : inferred!; final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase(); + markers.add( Marker( point: point, @@ -529,6 +572,8 @@ class _PathTraceMapScreenState extends State { ), ); } + hopLastLast = hopLast; + hopLast = hop; } final selfLat = context.read().selfLatitude; @@ -578,9 +623,9 @@ class _PathTraceMapScreenState extends State { // Add target contact endpoint marker. final targetPos = _targetContactPosition; - if (targetPos != null) { + if (targetPos != null && target != null && target.type == advTypeChat) { final isGuessed = _targetContactIsGuessed; - final targetName = widget.targetContact?.name ?? '?'; + final targetName = target.name; markers.add( Marker( point: targetPos, @@ -716,6 +761,7 @@ class _PathTraceMapScreenState extends State { Widget _buildMapPathTrace( BuildContext context, MapTileCacheService tileCache, + Contact? target, ) { return FlutterMap( key: _mapKey, @@ -754,6 +800,7 @@ class _PathTraceMapScreenState extends State { markers: _buildHopMarkers( _traceData!.pathData, showLabels: _showNodeLabels, + target: target, ), ), ], diff --git a/lib/services/app_debug_log_service.dart b/lib/services/app_debug_log_service.dart index c63e625..d31c3e5 100644 --- a/lib/services/app_debug_log_service.dart +++ b/lib/services/app_debug_log_service.dart @@ -51,6 +51,7 @@ class AppDebugLogService extends ChangeNotifier { String message, { String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info, + bool noNotify = false, }) { if (!_enabled && !kDebugMode) return; if (!_enabled) { @@ -72,22 +73,24 @@ class AppDebugLogService extends ChangeNotifier { _entries.removeRange(0, _entries.length - maxEntries); } - notifyListeners(); + if (!noNotify) { + notifyListeners(); + } // Also print to console for development debugPrint('[$tag] $message'); } - void info(String message, {String tag = 'App'}) { - log(message, tag: tag, level: AppDebugLogLevel.info); + void info(String message, {String tag = 'App', bool noNotify = false}) { + log(message, tag: tag, level: AppDebugLogLevel.info, noNotify: noNotify); } - void warn(String message, {String tag = 'App'}) { - log(message, tag: tag, level: AppDebugLogLevel.warning); + void warn(String message, {String tag = 'App', bool noNotify = false}) { + log(message, tag: tag, level: AppDebugLogLevel.warning, noNotify: noNotify); } - void error(String message, {String tag = 'App'}) { - log(message, tag: tag, level: AppDebugLogLevel.error); + void error(String message, {String tag = 'App', bool noNotify = false}) { + log(message, tag: tag, level: AppDebugLogLevel.error, noNotify: noNotify); } void clear() { diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart index e57261e..1f34a5e 100644 --- a/lib/utils/app_logger.dart +++ b/lib/utils/app_logger.dart @@ -23,23 +23,23 @@ class AppLogger { bool get isEnabled => _enabled; /// Log an info message - void info(String message, {String tag = 'App'}) { + void info(String message, {String tag = 'App', bool noNotify = false}) { if (_enabled && _service != null) { - _service!.info(message, tag: tag); + _service!.info(message, tag: tag, noNotify: noNotify); } } /// Log a warning message - void warn(String message, {String tag = 'App'}) { + void warn(String message, {String tag = 'App', bool noNotify = false}) { if (_enabled && _service != null) { - _service!.warn(message, tag: tag); + _service!.warn(message, tag: tag, noNotify: noNotify); } } /// Log an error message - void error(String message, {String tag = 'App'}) { + void error(String message, {String tag = 'App', bool noNotify = false}) { if (_enabled && _service != null) { - _service!.error(message, tag: tag); + _service!.error(message, tag: tag, noNotify: noNotify); } } @@ -48,9 +48,10 @@ class AppLogger { String message, { String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info, + bool noNotify = false, }) { if (_enabled && _service != null) { - _service!.log(message, tag: tag, level: level); + _service!.log(message, tag: tag, level: level, noNotify: noNotify); } } } diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index 0233b43..861241b 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -78,7 +78,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> { builder: (context) => PathTraceMapScreen( title: context.l10n.contacts_repeaterPathTrace, path: Uint8List.fromList(pathBytes), - flipPathRound: true, + flipPathAround: true, targetContact: widget.contact, ), ), From 4b744184c2b27072e6a07dbb31332d600390211f Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sat, 14 Mar 2026 18:09:54 -0700 Subject: [PATCH 04/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/models/contact.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 858d712..c047622 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -1,5 +1,4 @@ import 'dart:typed_data'; -import 'package:flutter/foundation.dart'; import 'package:meshcore_open/utils/app_logger.dart'; import '../connector/meshcore_protocol.dart'; From 9265daaf16aeaba5f6e4c5e120bdfd7d413e3bc3 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sat, 14 Mar 2026 18:10:09 -0700 Subject: [PATCH 05/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/screens/tcp_screen.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/screens/tcp_screen.dart b/lib/screens/tcp_screen.dart index 02b9b5a..11ab80a 100644 --- a/lib/screens/tcp_screen.dart +++ b/lib/screens/tcp_screen.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:meshcore_open/models/app_settings.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; From dc85e7a41c41aabcb684458116292dc9ace0e542 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sat, 14 Mar 2026 18:10:17 -0700 Subject: [PATCH 06/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/screens/path_trace_map.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index d50a185..86bba2a 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -361,7 +361,6 @@ class _PathTraceMapScreenState extends State { contact.longitude! + offsetDeg * sin(angle), ); targetGuessed = true; - targetGuessed = true; } } } From 3593cfa84397fb019c4d90f2a16cd9e8bac667d5 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sat, 14 Mar 2026 18:10:44 -0700 Subject: [PATCH 07/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/screens/path_trace_map.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 86bba2a..e3c877b 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -312,8 +312,8 @@ class _PathTraceMapScreenState extends State { targetPos = LatLng(target!.latitude!, target!.longitude!); } else if (widget.path.length > 1) { // Infer from the last hop: average GPS contacts sharing that hop. - // For a round-trip path (flipPathRound), the target-side hop sits - // in the middle of the symmetric sequence; .last is the local side. + // For a round-trip path (flipPathAround/reversePathAround), the target-side hop + // sits in the middle of the symmetric sequence; .last is the local side. final lastHop = widget.reversePathAround ? widget.path.first : widget.path.last; From 28a423e0a8df22bbaa3ad5e00cce7b271288fe8e Mon Sep 17 00:00:00 2001 From: zjs81 Date: Sat, 14 Mar 2026 18:14:39 -0700 Subject: [PATCH 08/10] fix: correct location validation and clean up target contact handling - Fix asymmetric lat/lon validation in _handleContactAdvert (was checking longitude != 0 for latitude; now uses (latitude != 0 || longitude != 0) for both) - Remove duplicate targetGuessed assignment in path_trace_map - Rename public target field to private _targetContact, use local variable to avoid unnecessary null-aware operators Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/connector/meshcore_connector.dart | 4 ++-- lib/screens/path_trace_map.dart | 22 ++++++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 86484d8..8f93f33 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -4757,14 +4757,14 @@ class MeshCoreConnector extends ChangeNotifier { hasLocation && latitude != null && latitude.abs() <= 90 && - longitude != 0 + (latitude != 0 || longitude != 0) ? latitude : existing.latitude, longitude: hasLocation && longitude != null && longitude.abs() <= 180 && - longitude != 0 + (latitude != 0 || longitude != 0) ? longitude : existing.longitude, name: hasName ? name : existing.name, diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index e3c877b..e64a906 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -93,7 +93,7 @@ class _PathTraceMapScreenState extends State { ValueKey _mapKey = const ValueKey('initial'); double _pathDistanceMeters = 0.0; bool _showNodeLabels = true; - Contact? target; + Contact? _targetContact; String _formatPathPrefixes(Uint8List pathBytes) { return pathBytes @@ -305,11 +305,12 @@ class _PathTraceMapScreenState extends State { // Compute endpoint position for the target contact. LatLng? targetPos; bool targetGuessed = false; - target = widget.targetContact; + _targetContact = widget.targetContact; - if (target != null) { - if (target?.hasLocation ?? false) { - targetPos = LatLng(target!.latitude!, target!.longitude!); + if (_targetContact != null) { + final tc = _targetContact!; + if (tc.hasLocation) { + targetPos = LatLng(tc.latitude!, tc.longitude!); } else if (widget.path.length > 1) { // Infer from the last hop: average GPS contacts sharing that hop. // For a round-trip path (flipPathAround/reversePathAround), the target-side hop @@ -334,7 +335,7 @@ class _PathTraceMapScreenState extends State { peers.map((c) => c.longitude!).reduce((a, b) => a + b) / peers.length; const offsetDeg = 0.003; - final angle = (target!.publicKey[1] / 255.0) * 2 * pi; + final angle = (tc.publicKey[1] / 255.0) * 2 * pi; targetPos = LatLng( lat + offsetDeg * cos(angle), lon + offsetDeg * sin(angle), @@ -344,7 +345,7 @@ class _PathTraceMapScreenState extends State { final lat = inferredPositions[lastHop]!.latitude; final lon = inferredPositions[lastHop]!.longitude; const offsetDeg = 0.003; - final angle = (target!.publicKey[1] / 255.0) * 2 * pi; + final angle = (tc.publicKey[1] / 255.0) * 2 * pi; targetPos = LatLng( lat + offsetDeg * cos(angle), lon + offsetDeg * sin(angle), @@ -355,7 +356,7 @@ class _PathTraceMapScreenState extends State { final contact = pathContacts[lastHop]; if (contact != null && contact.hasLocation) { const offsetDeg = 0.003; - final angle = (target!.publicKey[1] / 255.0) * 2 * pi; + final angle = (tc.publicKey[1] / 255.0) * 2 * pi; targetPos = LatLng( contact.latitude! + offsetDeg * cos(angle), contact.longitude! + offsetDeg * sin(angle), @@ -387,7 +388,7 @@ class _PathTraceMapScreenState extends State { hopLast = hop; } if (targetPos != null) { - if (target != null && target!.type == advTypeChat) { + if (_targetContact != null && _targetContact!.type == advTypeChat) { _points.add(targetPos); } } @@ -479,7 +480,8 @@ class _PathTraceMapScreenState extends State { ], ), ), - if (_hasData) _buildMapPathTrace(context, tileCache, target), + if (_hasData) + _buildMapPathTrace(context, tileCache, _targetContact), if (_points.isEmpty && !_hasData && !_isLoading && From 6dfb7a4b6941bc828069258724dc24988e5f7266 Mon Sep 17 00:00:00 2001 From: zjs81 Date: Sat, 14 Mar 2026 18:41:21 -0700 Subject: [PATCH 09/10] fix: auto-add flag parsing, contact cache restore, and USB reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix operator precedence bug in _handleAutoAddConfig where `flags & flag != 0` was parsed as `flags & (flag != 0)`, always checking bit 0 instead of the correct flag bit - Populate _contacts from cache in loadContactCache() so contacts persist across app restarts - Toggle DTR low→high on USB connect to force device to see a fresh connection - Add 10ms inter-frame delay for USB sends to prevent missed responses - Deassert DTR before closing USB port on disconnect/dispose Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/connector/meshcore_connector.dart | 17 ++++++++++----- lib/connector/meshcore_connector_usb.dart | 2 ++ lib/services/usb_serial_service_native.dart | 23 +++++++++++++++++++++ lib/services/usb_serial_service_web.dart | 11 ++++++++++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 8f93f33..1af3c0b 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -708,6 +708,9 @@ class MeshCoreConnector extends ChangeNotifier { _knownContactKeys ..clear() ..addAll(cached.map((c) => c.publicKeyHex)); + _contacts + ..clear() + ..addAll(cached); for (final contact in cached) { _ensureContactSmazSettingLoaded(contact.publicKeyHex); } @@ -1540,6 +1543,10 @@ class MeshCoreConnector extends ChangeNotifier { if (_activeTransport == MeshCoreTransportType.usb) { await _usbManager.write(data); + // Brief pause so the device firmware can process each frame before the + // next arrives. Without this, rapid-fire frames over USB can cause the + // device to miss responses (especially on reconnect). + await Future.delayed(const Duration(milliseconds: 10)); } else if (_activeTransport == MeshCoreTransportType.tcp) { await _tcpConnector.write(data); } else { @@ -4837,11 +4844,11 @@ class MeshCoreConnector extends ChangeNotifier { try { reader.skipBytes(1); // Skip the response code byte final flags = reader.readByte(); - _autoAddUsers = flags & autoAddChatFlag != 0; - _autoAddRepeaters = flags & autoAddRepeaterFlag != 0; - _autoAddRoomServers = flags & autoAddRoomServerFlag != 0; - _autoAddSensors = flags & autoAddSensorFlag != 0; - _overwriteOldest = flags & autoAddOverwriteOldestFlag != 0; + _autoAddUsers = (flags & autoAddChatFlag) != 0; + _autoAddRepeaters = (flags & autoAddRepeaterFlag) != 0; + _autoAddRoomServers = (flags & autoAddRoomServerFlag) != 0; + _autoAddSensors = (flags & autoAddSensorFlag) != 0; + _overwriteOldest = (flags & autoAddOverwriteOldestFlag) != 0; } catch (e) { appLogger.error('Failed to parse auto-add config: $e', tag: 'Connector'); } diff --git a/lib/connector/meshcore_connector_usb.dart b/lib/connector/meshcore_connector_usb.dart index 74e7355..56718bc 100644 --- a/lib/connector/meshcore_connector_usb.dart +++ b/lib/connector/meshcore_connector_usb.dart @@ -64,6 +64,8 @@ class MeshCoreUsbManager { Future write(Uint8List data) => _service.write(data); + Future writeRaw(Uint8List data) => _service.writeRaw(data); + // --- Label management --- void updateConnectedLabel(String selfName) { _service.updateConnectedLabel(selfName); diff --git a/lib/services/usb_serial_service_native.dart b/lib/services/usb_serial_service_native.dart index fca3d19..c1d3946 100644 --- a/lib/services/usb_serial_service_native.dart +++ b/lib/services/usb_serial_service_native.dart @@ -189,6 +189,10 @@ class UsbSerialService { serial.setStopBits1(); serial.setFlowControlNone(); serial.setRTS(false); + // Toggle DTR low→high so the device sees a fresh connection even + // if the previous disconnect didn't cleanly signal DTR drop. + serial.setDTR(false); + await Future.delayed(const Duration(milliseconds: 50)); serial.setDTR(true); _serial = serial; // Update the normalized port name to whichever candidate succeeded. @@ -249,6 +253,23 @@ class UsbSerialService { _status = UsbSerialStatus.connected; } + Future writeRaw(Uint8List data) async { + if (!isConnected) { + throw StateError('USB serial port is not open'); + } + if (_useAndroidUsbHost) { + try { + await _androidMethodChannel.invokeMethod('write', { + 'data': data, + }); + } on PlatformException catch (error) { + throw StateError(error.message ?? error.code); + } + } else { + _serial!.write(data); + } + } + Future write(Uint8List data) async { if (!isConnected) { throw StateError('USB serial port is not open'); @@ -300,6 +321,7 @@ class UsbSerialService { _serial = null; try { if (serial?.isOpen() == FlOpenStatus.open) { + serial?.setDTR(false); serial?.closePort(); } } catch (_) { @@ -350,6 +372,7 @@ class UsbSerialService { final serial = _serial; try { if (serial?.isOpen() == FlOpenStatus.open) { + serial?.setDTR(false); serial?.closePort(); // synchronous C call — kills the SerialThread } } catch (_) {} diff --git a/lib/services/usb_serial_service_web.dart b/lib/services/usb_serial_service_web.dart index 4c83d7d..5261308 100644 --- a/lib/services/usb_serial_service_web.dart +++ b/lib/services/usb_serial_service_web.dart @@ -127,6 +127,17 @@ class UsbSerialService { } } + Future writeRaw(Uint8List data) async { + if (!isConnected || _writer == null) { + throw StateError('USB serial port is not open'); + } + final promise = _writer!.callMethod>( + 'write'.toJS, + data.toJS, + ); + await promise.toDart; + } + Future write(Uint8List data) async { if (!isConnected || _writer == null) { throw StateError('USB serial port is not open'); From 60e8ee013053a06d1f8c74d8f654d3ecf97f0288 Mon Sep 17 00:00:00 2001 From: zjs81 Date: Sat, 14 Mar 2026 18:41:57 -0700 Subject: [PATCH 10/10] fix: simplify method call for writing data in UsbSerialService --- lib/services/usb_serial_service_native.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/services/usb_serial_service_native.dart b/lib/services/usb_serial_service_native.dart index c1d3946..40861db 100644 --- a/lib/services/usb_serial_service_native.dart +++ b/lib/services/usb_serial_service_native.dart @@ -259,9 +259,7 @@ class UsbSerialService { } if (_useAndroidUsbHost) { try { - await _androidMethodChannel.invokeMethod('write', { - 'data': data, - }); + await _androidMethodChannel.invokeMethod('write', {'data': data}); } on PlatformException catch (error) { throw StateError(error.message ?? error.code); }